Add passkey / biometric login (WebAuthn FIDO2)

Shop floor workers can log in once with a password, enroll a passkey,
and use Face ID / Windows Hello / fingerprint for all future logins.

- UserPasskey entity + AddUserPasskeys migration (Fido2 v4.0.1)
- PasskeyController: RegisterOptions, Register, LoginOptions, Login,
  Manage, Remove endpoints
- Login page: platform-aware button (Face ID / Windows Hello / etc.)
  hidden automatically if browser doesn't support WebAuthn
- Post-login floating prompt to enroll on first use; session-dismissed
- Passkeys & Biometrics link in user dropdown menu
- Manage page: list registered devices, add new, remove individual
- passkey.js: targeted base64url conversion (only challenge + user.id
  + credential IDs) — fixes "Required parameters missing" error caused
  by blindly converting rp.id and other string fields to ArrayBuffers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 15:07:01 -04:00
parent 4f976b1332
commit 0bb96a502a
16 changed files with 16101 additions and 17 deletions
@@ -0,0 +1,11 @@
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
@@ -0,0 +1,42 @@
document.addEventListener('DOMContentLoaded', async () => {
const addBtn = document.getElementById('pk-add-btn');
const statusEl = document.getElementById('pk-add-status');
const deviceNameInput = document.getElementById('pk-device-name');
if (!addBtn) return;
const supported = await passkeySupported();
if (!supported) {
addBtn.disabled = true;
addBtn.textContent = 'Not supported on this browser';
if (statusEl) {
statusEl.textContent = 'Your browser does not support passkeys. Try Safari on iOS 16+, Chrome 108+, or Edge 108+.';
statusEl.className = 'mt-2 small mb-0 text-muted';
}
return;
}
addBtn.addEventListener('click', async () => {
const name = deviceNameInput.value.trim();
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Follow the prompt…';
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'mt-2 small mb-0'; }
const result = await registerPasskey(name);
if (result.success) {
if (statusEl) {
statusEl.textContent = '✓ Passkey added! Reloading…';
statusEl.className = 'mt-2 small mb-0 text-success';
}
setTimeout(() => window.location.reload(), 1200);
} else {
addBtn.disabled = false;
addBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Passkey';
if (statusEl) {
statusEl.textContent = result.error || 'Setup failed. Please try again.';
statusEl.className = 'mt-2 small mb-0 text-danger';
}
}
});
});
+321
View File
@@ -0,0 +1,321 @@
/**
* 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 this browser + platform support WebAuthn conditional UI (passkeys). */
async function passkeySupported() {
if (!window.PublicKeyCredential) return false;
try {
return await PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
} 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}`;
}
});
});
// ─── Post-login prompt wiring (layout) ───────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const prompt = document.getElementById('passkey-setup-prompt');
if (!prompt) return;
const supported = await passkeySupported();
if (!supported) { prompt.remove(); return; }
const label = passkeyLabel();
const enableBtn = document.getElementById('passkey-enable-btn');
if (enableBtn) enableBtn.innerHTML = `<i class="bi bi-fingerprint me-1"></i>Enable ${label.replace('Use ', '')}`;
prompt.classList.remove('d-none');
const dismissBtn = document.getElementById('passkey-dismiss-btn');
const statusEl = document.getElementById('passkey-setup-status');
enableBtn?.addEventListener('click', async () => {
enableBtn.disabled = true;
if (statusEl) statusEl.textContent = 'Follow the prompt on your device…';
const ua = navigator.userAgent;
const deviceName = /iphone/i.test(ua) ? 'iPhone'
: /ipad/i.test(ua) ? 'iPad'
: /android/i.test(ua) ? 'Android device'
: /macintosh/i.test(ua) ? 'Mac'
: /windows/i.test(ua) ? 'Windows PC'
: 'This device';
const result = await registerPasskey(deviceName);
if (result.success) {
if (statusEl) {
statusEl.textContent = `${label.replace('Use ', '')} enabled for this device!`;
statusEl.className = 'small mb-0 text-success';
}
enableBtn.classList.add('d-none');
if (dismissBtn) dismissBtn.textContent = 'Close';
setTimeout(() => prompt.classList.add('d-none'), 3000);
} else {
enableBtn.disabled = false;
enableBtn.innerHTML = `<i class="bi bi-fingerprint me-1"></i>Enable ${label.replace('Use ', '')}`;
if (statusEl) statusEl.textContent = result.error || 'Setup failed. Try again.';
}
});
dismissBtn?.addEventListener('click', () => {
prompt.classList.add('d-none');
// Remember dismissal for this session so it doesn't re-appear on every page load
sessionStorage.setItem('passkey-prompt-dismissed', '1');
});
if (sessionStorage.getItem('passkey-prompt-dismissed')) {
prompt.classList.add('d-none');
}
});