Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/passkey.js
T
spouliot edce8e8c4a Move passkey enrollment prompt to post-login dedicated page
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>
2026-04-25 16:41:01 -04:00

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}`;
}
});
});