Add Passkeys Support
Closes gh-13305
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const holder = {
|
||||
controller: new AbortController(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a new AbortSignal to be used in the options for the registration and authentication ceremonies.
|
||||
* Aborts the existing AbortController if it exists, cancelling any existing ceremony.
|
||||
*
|
||||
* The authentication ceremony, when triggered with conditional mediation, shows a non-modal
|
||||
* interaction. If the user does not interact with the non-modal dialog, the existing ceremony MUST
|
||||
* be cancelled before initiating a new one, hence the need for a singleton AbortController.
|
||||
*
|
||||
* @returns {AbortSignal} a new, non-aborted AbortSignal
|
||||
*/
|
||||
function newSignal() {
|
||||
if (!!holder.controller) {
|
||||
holder.controller.abort("Initiating new WebAuthN ceremony, cancelling current ceremony");
|
||||
}
|
||||
holder.controller = new AbortController();
|
||||
return holder.controller.signal;
|
||||
}
|
||||
|
||||
export default {
|
||||
newSignal,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
export default {
|
||||
encode: function (buffer) {
|
||||
const base64 = window.btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
||||
return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
},
|
||||
decode: function (base64url) {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const binStr = window.atob(base64);
|
||||
const bin = new Uint8Array(binStr.length);
|
||||
for (let i = 0; i < binStr.length; i++) {
|
||||
bin[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
return bin.buffer;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
async function post(url, headers, body) {
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
export default { post };
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import { setupLogin } from "./webauthn-login.js";
|
||||
import { setupRegistration } from "./webauthn-registration.js";
|
||||
|
||||
// Make "setup" available in the window domain, so it can be run with "setupLogin()"
|
||||
window.setupLogin = setupLogin;
|
||||
window.setupRegistration = setupRegistration;
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import base64url from "./base64url.js";
|
||||
import http from "./http.js";
|
||||
import abortController from "./abort-controller.js";
|
||||
|
||||
async function isConditionalMediationAvailable() {
|
||||
return !!(
|
||||
window.PublicKeyCredential &&
|
||||
window.PublicKeyCredential.isConditionalMediationAvailable &&
|
||||
(await window.PublicKeyCredential.isConditionalMediationAvailable())
|
||||
);
|
||||
}
|
||||
|
||||
async function authenticate(headers, contextPath, useConditionalMediation) {
|
||||
let options;
|
||||
try {
|
||||
const optionsResponse = await http.post(`${contextPath}/webauthn/authenticate/options`, headers);
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(`HTTP ${optionsResponse.status}`);
|
||||
}
|
||||
options = await optionsResponse.json();
|
||||
} catch (err) {
|
||||
throw new Error(`Authentication failed. Could not fetch authentication options: ${err.message}`, { cause: err });
|
||||
}
|
||||
|
||||
// FIXME: Use https://www.w3.org/TR/webauthn-3/#sctn-parseRequestOptionsFromJSON
|
||||
const decodedOptions = {
|
||||
...options,
|
||||
challenge: base64url.decode(options.challenge),
|
||||
};
|
||||
|
||||
// Invoke the WebAuthn get() method.
|
||||
const credentialOptions = {
|
||||
publicKey: decodedOptions,
|
||||
signal: abortController.newSignal(),
|
||||
};
|
||||
if (useConditionalMediation) {
|
||||
// Request a conditional UI
|
||||
credentialOptions.mediation = "conditional";
|
||||
}
|
||||
|
||||
let cred;
|
||||
try {
|
||||
cred = await navigator.credentials.get(credentialOptions);
|
||||
} catch (err) {
|
||||
throw new Error(`Authentication failed. Call to navigator.credentials.get failed: ${err.message}`, { cause: err });
|
||||
}
|
||||
|
||||
const { response, type: credType } = cred;
|
||||
let userHandle;
|
||||
if (response.userHandle) {
|
||||
userHandle = base64url.encode(response.userHandle);
|
||||
}
|
||||
const body = {
|
||||
id: cred.id,
|
||||
rawId: base64url.encode(cred.rawId),
|
||||
response: {
|
||||
authenticatorData: base64url.encode(response.authenticatorData),
|
||||
clientDataJSON: base64url.encode(response.clientDataJSON),
|
||||
signature: base64url.encode(response.signature),
|
||||
userHandle,
|
||||
},
|
||||
credType,
|
||||
clientExtensionResults: cred.getClientExtensionResults(),
|
||||
authenticatorAttachment: cred.authenticatorAttachment,
|
||||
};
|
||||
|
||||
let authenticationResponse;
|
||||
try {
|
||||
const authenticationCallResponse = await http.post(`${contextPath}/login/webauthn`, headers, body);
|
||||
if (!authenticationCallResponse.ok) {
|
||||
throw new Error(`HTTP ${authenticationCallResponse.status}`);
|
||||
}
|
||||
authenticationResponse = await authenticationCallResponse.json();
|
||||
// if (authenticationResponse && authenticationResponse.authenticated) {
|
||||
} catch (err) {
|
||||
throw new Error(`Authentication failed. Could not process the authentication request: ${err.message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(authenticationResponse && authenticationResponse.authenticated && authenticationResponse.redirectUrl)) {
|
||||
throw new Error(
|
||||
`Authentication failed. Expected {"authenticated": true, "redirectUrl": "..."}, server responded with: ${JSON.stringify(authenticationResponse)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return authenticationResponse.redirectUrl;
|
||||
}
|
||||
|
||||
async function register(headers, contextPath, label) {
|
||||
if (!label) {
|
||||
throw new Error("Error: Passkey Label is required");
|
||||
}
|
||||
|
||||
let options;
|
||||
try {
|
||||
const optionsResponse = await http.post(`${contextPath}/webauthn/register/options`, headers);
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(`Server responded with HTTP ${optionsResponse.status}`);
|
||||
}
|
||||
options = await optionsResponse.json();
|
||||
} catch (e) {
|
||||
throw new Error(`Registration failed. Could not fetch registration options: ${e.message}`, { cause: e });
|
||||
}
|
||||
|
||||
// FIXME: Use https://www.w3.org/TR/webauthn-3/#sctn-parseCreationOptionsFromJSON
|
||||
const decodedExcludeCredentials = !options.excludeCredentials
|
||||
? []
|
||||
: options.excludeCredentials.map((cred) => ({
|
||||
...cred,
|
||||
id: base64url.decode(cred.id),
|
||||
}));
|
||||
|
||||
const decodedOptions = {
|
||||
...options,
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64url.decode(options.user.id),
|
||||
},
|
||||
challenge: base64url.decode(options.challenge),
|
||||
excludeCredentials: decodedExcludeCredentials,
|
||||
};
|
||||
|
||||
let credentialsContainer;
|
||||
try {
|
||||
credentialsContainer = await navigator.credentials.create({
|
||||
publicKey: decodedOptions,
|
||||
signal: abortController.newSignal(),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Registration failed. Call to navigator.credentials.create failed: ${e.message}`, { cause: e });
|
||||
}
|
||||
|
||||
// FIXME: Let response be credential.response. If response is not an instance of AuthenticatorAttestationResponse, abort the ceremony with a user-visible error. https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
|
||||
const { response } = credentialsContainer;
|
||||
const credential = {
|
||||
id: credentialsContainer.id,
|
||||
rawId: base64url.encode(credentialsContainer.rawId),
|
||||
response: {
|
||||
attestationObject: base64url.encode(response.attestationObject),
|
||||
clientDataJSON: base64url.encode(response.clientDataJSON),
|
||||
transports: response.getTransports ? response.getTransports() : [],
|
||||
},
|
||||
type: credentialsContainer.type,
|
||||
clientExtensionResults: credentialsContainer.getClientExtensionResults(),
|
||||
authenticatorAttachment: credentialsContainer.authenticatorAttachment,
|
||||
};
|
||||
|
||||
const registrationRequest = {
|
||||
publicKey: {
|
||||
credential: credential,
|
||||
label: label,
|
||||
},
|
||||
};
|
||||
|
||||
let verificationJSON;
|
||||
try {
|
||||
const verificationResp = await http.post(`${contextPath}/webauthn/register`, headers, registrationRequest);
|
||||
if (!verificationResp.ok) {
|
||||
throw new Error(`HTTP ${verificationResp.status}`);
|
||||
}
|
||||
verificationJSON = await verificationResp.json();
|
||||
} catch (e) {
|
||||
throw new Error(`Registration failed. Could not process the registration request: ${e.message}`, { cause: e });
|
||||
}
|
||||
|
||||
if (!(verificationJSON && verificationJSON.success)) {
|
||||
throw new Error(`Registration failed. Server responded with: ${JSON.stringify(verificationJSON)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
authenticate,
|
||||
register,
|
||||
isConditionalMediationAvailable,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import webauthn from "./webauthn-core.js";
|
||||
|
||||
async function authenticateOrError(headers, contextPath, useConditionalMediation) {
|
||||
try {
|
||||
const redirectUrl = await webauthn.authenticate(headers, contextPath, useConditionalMediation);
|
||||
window.location.href = redirectUrl;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
window.location.href = `${contextPath}/login?error`;
|
||||
}
|
||||
}
|
||||
|
||||
async function conditionalMediation(headers, contextPath) {
|
||||
const available = await webauthn.isConditionalMediationAvailable();
|
||||
if (available) {
|
||||
await authenticateOrError(headers, contextPath, true);
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
export async function setupLogin(headers, contextPath, signinButton) {
|
||||
signinButton.addEventListener("click", async () => {
|
||||
await authenticateOrError(headers, contextPath, false);
|
||||
});
|
||||
|
||||
// FIXME: conditional mediation triggers browser crashes
|
||||
// See: https://github.com/rwinch/spring-security-webauthn/issues/73
|
||||
// await conditionalMediation(headers, contextPath);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import webauthn from "./webauthn-core.js";
|
||||
|
||||
function setVisibility(element, value) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.style.display = value ? "block" : "none";
|
||||
}
|
||||
|
||||
function setError(ui, msg) {
|
||||
resetPopups(ui);
|
||||
const error = ui.getError();
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
error.textContent = msg;
|
||||
setVisibility(error, true);
|
||||
}
|
||||
|
||||
function setSuccess(ui) {
|
||||
resetPopups(ui);
|
||||
const success = ui.getSuccess();
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
setVisibility(success, true);
|
||||
}
|
||||
|
||||
function resetPopups(ui) {
|
||||
const success = ui.getSuccess();
|
||||
const error = ui.getError();
|
||||
setVisibility(success, false);
|
||||
setVisibility(error, false);
|
||||
}
|
||||
|
||||
async function submitDeleteForm(contextPath, form, headers) {
|
||||
const options = {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
await fetch(form.action, options);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param headers headers added to the credentials creation POST request, typically CSRF
|
||||
* @param contextPath the contextPath from which the app is served
|
||||
* @param ui contains getRegisterButton(), getSuccess(), getError(), getLabelInput(), getDeleteForms()
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function setupRegistration(headers, contextPath, ui) {
|
||||
resetPopups(ui);
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
setError(ui, "WebAuthn is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(window.location.search);
|
||||
if (queryString.has("success")) {
|
||||
setSuccess(ui);
|
||||
}
|
||||
|
||||
ui.getRegisterButton().addEventListener("click", async () => {
|
||||
resetPopups(ui);
|
||||
const label = ui.getLabelInput().value;
|
||||
try {
|
||||
await webauthn.register(headers, contextPath, label);
|
||||
window.location.href = `${contextPath}/webauthn/register?success`;
|
||||
} catch (err) {
|
||||
setError(ui, err.message);
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
ui.getDeleteForms().forEach((form) =>
|
||||
form.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await submitDeleteForm(contextPath, form, headers);
|
||||
window.location.href = `${contextPath}/webauthn/register?success`;
|
||||
} catch (err) {
|
||||
setError(ui, err.message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user