/** * passkey.js — WebAuthn / FIDO2 client helpers. * * WebAuthn deals in raw ArrayBuffers; the server (and Fido2NetLib) serialize * them as base64url strings. These helpers convert back and forth so the JS * fetch payloads match what the server expects. */ // ─── base64url helpers ──────────────────────────────────────────────────────── function bufferToBase64url(buffer) { const bytes = new Uint8Array(buffer); let str = ''; for (const b of bytes) str += String.fromCharCode(b); return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } function base64urlToBuffer(base64url) { const padded = base64url.replace(/-/g, '+').replace(/_/g, '/'); const binary = atob(padded); const buffer = new ArrayBuffer(binary.length); const bytes = new Uint8Array(buffer); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return buffer; } /** * Converts server-side CredentialCreateOptions into the shape expected by * navigator.credentials.create(). Only the fields that the WebAuthn spec * requires as ArrayBuffer are converted — everything else (rp.id, attestation, * type strings, etc.) must stay as plain strings. */ function prepareCreateOptions(opts) { return { ...opts, challenge: base64urlToBuffer(opts.challenge), user: { ...opts.user, id: base64urlToBuffer(opts.user.id) }, excludeCredentials: (opts.excludeCredentials ?? []).map(c => ({ ...c, id: base64urlToBuffer(c.id) })) }; } /** * Converts server-side AssertionOptions into the shape expected by * navigator.credentials.get(). Only challenge and allowCredentials[].id * need to be ArrayBuffers. */ function prepareGetOptions(opts) { return { ...opts, challenge: base64urlToBuffer(opts.challenge), allowCredentials: (opts.allowCredentials ?? []).map(c => ({ ...c, id: base64urlToBuffer(c.id) })) }; } /** Recursively convert all ArrayBuffers in a credential to base64url strings for JSON. */ function encodeCredential(obj) { if (obj instanceof ArrayBuffer) return bufferToBase64url(obj); if (ArrayBuffer.isView(obj)) return bufferToBase64url(obj.buffer.slice(obj.byteOffset, obj.byteOffset + obj.byteLength)); if (Array.isArray(obj)) return obj.map(encodeCredential); if (obj && typeof obj === 'object') { const out = {}; for (const [k, v] of Object.entries(obj)) out[k] = encodeCredential(v); return out; } return obj; } // ─── Registration (after password login) ───────────────────────────────────── /** * Starts the passkey registration flow for the currently signed-in user. * @param {string} deviceName Optional friendly name (e.g. "Scott's iPhone") */ async function registerPasskey(deviceName) { try { // 1. Ask the server for creation options (challenge) const optRes = await fetch('/Passkey/RegisterOptions', { method: 'POST' }); if (!optRes.ok) throw new Error(await optRes.text()); const optionsRaw = await optRes.json(); // 2. Convert server options into WebAuthn API format const options = prepareCreateOptions(optionsRaw); // 3. Prompt the authenticator (FaceID / fingerprint / security key) const credential = await navigator.credentials.create({ publicKey: options }); if (!credential) throw new Error('No credential returned from authenticator.'); // 4. Build the response payload (all ArrayBuffers → base64url) const payload = { id: bufferToBase64url(credential.rawId), rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { attestationObject: bufferToBase64url(credential.response.attestationObject), clientDataJSON: bufferToBase64url(credential.response.clientDataJSON) } }; // 5. Send to server const qs = deviceName ? `?deviceName=${encodeURIComponent(deviceName)}` : ''; const regRes = await fetch(`/Passkey/Register${qs}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!regRes.ok) { const err = await regRes.json().catch(() => ({ error: regRes.statusText })); throw new Error(err.error || 'Registration failed.'); } return { success: true }; } catch (err) { console.error('Passkey registration error:', err); return { success: false, error: err.message }; } } // ─── Authentication (at login page) ────────────────────────────────────────── /** * Attempts to sign in using a registered passkey. * Resolves with { success, redirectUrl } or { success: false, error }. */ async function loginWithPasskey() { try { // 1. Get assertion options from the server const optRes = await fetch('/Passkey/LoginOptions', { method: 'POST' }); if (!optRes.ok) throw new Error(await optRes.text()); const optionsRaw = await optRes.json(); // 2. Convert to WebAuthn format const options = prepareGetOptions(optionsRaw); // 3. Prompt authenticator — browser shows passkey picker automatically const assertion = await navigator.credentials.get({ publicKey: options }); if (!assertion) throw new Error('No credential returned.'); // 4. Build payload const payload = { id: bufferToBase64url(assertion.rawId), rawId: bufferToBase64url(assertion.rawId), type: assertion.type, response: { authenticatorData: bufferToBase64url(assertion.response.authenticatorData), clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), signature: bufferToBase64url(assertion.response.signature), userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : null } }; // 5. Verify on server const loginRes = await fetch('/Passkey/Login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!loginRes.ok) { const err = await loginRes.json().catch(() => ({ error: loginRes.statusText })); throw new Error(err.error || 'Login failed.'); } const data = await loginRes.json(); return { success: true, redirectUrl: data.redirectUrl }; } catch (err) { if (err.name === 'NotAllowedError') { // User cancelled or timed out — not a real error return { success: false, cancelled: true }; } console.error('Passkey login error:', err); return { success: false, error: err.message }; } } // ─── Feature detection ──────────────────────────────────────────────────────── /** * True if the device has a user-verifying platform authenticator (Face ID, * fingerprint, Windows Hello, etc.) that can handle our modal passkey flow. * * Deliberately uses isUserVerifyingPlatformAuthenticatorAvailable() rather than * isConditionalMediationAvailable(). The conditional API signals to iOS Safari * that the page wants autofill-style passkey interception, which causes iOS 17+ * to show its own native passkey enrollment sheet when the password form is * submitted — not what we want. The platform authenticator check simply asks * "can this device do biometrics?" with no side-effects. */ async function passkeySupported() { if (!window.PublicKeyCredential) return false; try { return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } catch { return false; } } /** * Returns a platform-appropriate label for the passkey button, e.g. * "Use Face ID / Touch ID" on iOS, "Use Windows Hello" on Windows. */ function passkeyLabel() { const ua = navigator.userAgent; const platform = navigator.platform ?? ''; // iOS / iPadOS (iPads report MacIntel on platform in some browsers — check UA too) if (/iphone|ipad|ipod/i.test(ua) || (/macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) { return 'Use Face ID / Touch ID'; } // Android if (/android/i.test(ua)) { return 'Use Fingerprint / Face Unlock'; } // macOS (non-touch — Touch ID on MacBook) if (/mac/i.test(platform) || /macintosh/i.test(ua)) { return 'Use Touch ID'; } // Windows if (/win/i.test(platform) || /windows/i.test(ua)) { return 'Use Windows Hello'; } // ChromeOS / Linux / unknown return 'Use Passkey / Biometric'; } // ─── Login page wiring ──────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { const passkeyBtn = document.getElementById('passkey-login-btn'); if (!passkeyBtn) return; const supported = await passkeySupported(); if (!supported) { passkeyBtn.closest('.passkey-login-section')?.remove(); return; } const label = passkeyLabel(); passkeyBtn.innerHTML = ` ${label}`; passkeyBtn.addEventListener('click', async () => { passkeyBtn.disabled = true; passkeyBtn.textContent = 'Waiting for authentication…'; const result = await loginWithPasskey(); if (result.success) { window.location.href = result.redirectUrl || '/'; } else if (!result.cancelled) { passkeyBtn.disabled = false; passkeyBtn.innerHTML = ` ${label}`; const errEl = document.getElementById('passkey-error'); if (errEl) { errEl.textContent = result.error || 'Authentication failed. Try again.'; errEl.classList.remove('d-none'); } } else { passkeyBtn.disabled = false; passkeyBtn.innerHTML = ` ${label}`; } }); });