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:
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user