phoenix_passkeys/assets/js/passkeys.js

215 lines
6.4 KiB
JavaScript

document.addEventListener("DOMContentLoaded", () => {
const registrationForm = document.getElementById("registration-form");
if (registrationForm) {
registrationForm.addEventListener("submit", (event) => {
event.preventDefault();
registerWebAuthnAccount(registrationForm, false);
});
}
const resetForm = document.getElementById("reset-passkey-form");
if (resetForm) {
resetForm.addEventListener("submit", (event) => {
event.preventDefault();
registerWebAuthnAccount(resetForm, true);
});
}
const loginForm = document.getElementById("login-form");
if (loginForm) {
loginWebAuthnAccount(loginForm, true);
loginForm.addEventListener("submit", (event) => {
event.preventDefault();
loginWebAuthnAccount(loginForm, false);
});
}
});
async function registerWebAuthnAccount(form, resetting) {
if (!(await supportsPasskeys())) {
return;
}
const createOptions = {
publicKey: {
rp: {
// In reality, this would be the actual app domain.
id: "localhost",
// User-facing name of the service.
name: "Phoenix Passkeys",
},
user: {
// Generate a random value to use as the user ID, since we don't have a user yet.
// This is used to to overwrite existing credentials with the same RP & user ID.
// Since the user is registering, we're assuming they don't want to do that.
// This is also returned as response.userHandle from navigator.credentials.get, which
// could be used to identify the logging-in user. We don't use it for that (and don't
// need to store it) because a credential only belongs to 1 account and so we can
// directly use the credential to identify the account.
id: generateUserID(),
// The email the user entered.
name: form["user[email]"].value,
// Required, but we don't have anything better to display than the email.
displayName: "",
},
pubKeyCredParams: [
{
type: "public-key",
// Ed25519
alg: -8,
},
{
type: "public-key",
// ES256
alg: -7,
},
{
type: "public-key",
// RS256
alg: -257,
},
],
// Unused during registration.
challenge: new Uint8Array([0]),
authenticatorSelection: {
// We only want the platform (device) authenticator, not a roaming authenticator (such as a physical security key).
authenticatorAttachment: "platform",
// We want a discoverable credential, so that it's available when we request credentials for login.
// Being discoverable is what means we don't require a separate uesrname entry step.
requireResidentKey: true,
},
timeout: 3 * 60 * 1000,
}
};
try {
const credential = await navigator.credentials.create(createOptions);
if (!credential) {
alert("Could not create credential");
return
}
const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);
const clientData = JSON.parse(clientDataJSON);
if (clientData.type !== "webauthn.create" || (("crossOrigin" in clientData) && clientData.crossOrigin) || clientData.origin !== "http://localhost:4000") {
alert("Invalid credential");
return;
}
const authenticatorData = new Uint8Array(credential.response.getAuthenticatorData());
const idLength = (authenticatorData[53] << 8) | authenticatorData[54];
const id = authenticatorData.slice(55, 55 + idLength);
const publicKey = credential.response.getPublicKey();
const body = new FormData();
body.append("_csrf_token", form._csrf_token.value);
body.append("email", form["user[email]"].value);
body.append("credential_id", arrayBufferToBase64(id));
body.append("public_key_spki", arrayBufferToBase64(publicKey));
const resp = await fetch(form.action, {
method: resetting ? "PUT" : "POST",
body,
});
const respJSON = await resp.json();
if (respJSON.status === "ok") {
if (resetting) {
// Reset successful, redirect to login page
window.location = "/users/log_in";
} else {
// Registration successful, redirect to homepage
window.location = "/";
}
} else {
alert("Registration failed");
return;
}
} catch (e) {
alert(`WebAuthn enrollment failed: ${e}`);
}
}
async function loginWebAuthnAccount(loginForm, conditional) {
if (!(await supportsPasskeys())) {
return;
}
// Get the challenge generated by the server.
const challenge = loginForm.challenge.value;
if (!challenge) {
return;
}
const getOptions = {
mediation: conditional ? "conditional" : "optional",
publicKey: {
challenge: base64URLToArrayBuffer(challenge),
rpId: "localhost",
}
};
try {
const credential = await navigator.credentials.get(getOptions);
const body = new FormData();
body.append("_csrf_token", loginForm._csrf_token.value);
body.append("raw_id", arrayBufferToBase64(credential.rawId));
body.append("client_data_json", arrayBufferToBase64(credential.response.clientDataJSON));
body.append("authenticator_data", arrayBufferToBase64(credential.response.authenticatorData));
body.append("signature", arrayBufferToBase64(credential.response.signature));
const resp = await fetch("/users/log_in", {
method: "POST",
body,
});
const respJSON = await resp.json();
if (respJSON?.status === "ok") {
// Login successful, redirect to homepage.
window.location = "/";
} else {
alert("Login failed");
return;
}
} catch (e) {
alert(`WebAuthn login failed: ${e}`);
}
}
async function supportsPasskeys() {
if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
return false;
}
const [conditional, userVerifiying] = await Promise.all([
PublicKeyCredential.isConditionalMediationAvailable(),
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
]);
return conditional && userVerifiying;
}
function generateUserID() {
const userID = new Uint8Array(64);
crypto.getRandomValues(userID);
return userID;
}
function arrayBufferToBase64(buf) {
let binary = "";
const bytes = new Uint8Array(buf);
for (let i = 0; i < buf.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Note that this function deals in Base64 URL, not regular Base64.
function base64URLToArrayBuffer(b64) {
// Convert base64url to base64 so we can use atob.
const converted = b64.replace(/[-_]/g, (c) => c === "-" ? "+" : "/");
const bin = atob(converted);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i);
}
return bytes.buffer;
}