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 username entry step. requireResidentKey: true, }, } }; 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 backedUp = (authenticatorData[32] >> 4) & 1; const idLength = (authenticatorData[53] << 8) | authenticatorData[54]; const id = authenticatorData.slice(55, 55 + idLength); const publicKey = credential.response.getPublicKey(); if (backedUp !== 1) { alert("Can't register with non backed-up credential"); return; } 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; } let allowCredentials = []; if (!conditional) { const email = loginForm["user[email]"].value; const resp = await fetch(`/users/log_in/credentials?email=${email}`); const respJSON = await resp.json(); allowCredentials = respJSON.map((id) => { return { type: "public-key", id: base64ToArrayBuffer(id), }; }); } const getOptions = { mediation: conditional ? "conditional" : "optional", publicKey: { challenge: base64URLToArrayBuffer(challenge), rpId: "localhost", allowCredentials, } }; 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) { const converted = b64.replace(/[-_]/g, (c) => c === "-" ? "+" : "/"); return base64ToArrayBuffer(converted); } function base64ToArrayBuffer(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); } return bytes.buffer; }