edce8e8c4a
After password login, users are routed through /Passkey/EnrollPrompt before reaching the dashboard. The page shows an Enable / Maybe later choice using the auth layout for a clean full-screen experience. Users who already have a passkey are skipped past instantly. Removes the floating bottom-right card from _Layout — the dedicated page is a better UX touchpoint (one moment, right after login, rather than a floating card on every page). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
/**
|
|
* 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 = `<i class="bi bi-fingerprint"></i> ${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 = `<i class="bi bi-fingerprint"></i> ${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 = `<i class="bi bi-fingerprint"></i> ${label}`;
|
|
}
|
|
});
|
|
});
|
|
|