feat: add passkey (WebAuthn) authentication support - #79
Conversation
Implements passwordless authentication using passkeys: - Add @simplewebauthn/server dependency for WebAuthn operations - Add passkey storage to accounts (credentialId, publicKey, counter) - Add credential index for fast lookup by credential ID - Create /idp/passkey/* endpoints for registration and login - Update login page with "Sign in with Passkey" button - Add post-login prompt to encourage passkey setup - Add passkey-complete and passkey-skip interaction handlers User flow: 1. Login with password -> see "Add a Passkey?" prompt 2. Click "Add Passkey" -> Touch ID/Face ID -> passkey stored 3. Next login -> click "Sign in with Passkey" -> Touch ID/Face ID -> logged in Implements Phase 1 of #78
There was a problem hiding this comment.
Pull request overview
This PR implements Phase 1 of passwordless authentication using passkeys (WebAuthn/FIDO2). Users can now log in with Touch ID, Face ID, or security keys, and are prompted to add a passkey after traditional password login.
Changes:
- Adds WebAuthn server-side implementation with registration and authentication endpoints
- Introduces passkey storage in account objects with credential indexing
- Implements post-login passkey prompt UI with skip functionality
- Adds "Sign in with Passkey" button to the login page
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 20 comments.
Show a summary per file
| File | Description |
|---|---|
package.json |
Adds @simplewebauthn/server dependency for WebAuthn support |
src/idp/passkey.js |
New file implementing WebAuthn registration and authentication endpoints |
src/idp/accounts.js |
Adds passkey storage methods, credential index, and account update functions |
src/idp/views.js |
Adds passkey button to login page and new passkey prompt page with client-side WebAuthn JavaScript |
src/idp/interactions.js |
Modifies login flow to show passkey prompt and adds completion/skip handlers |
src/idp/index.js |
Registers new passkey routes with rate limiting on verify endpoints |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Verify and store the registration response | ||
| */ | ||
| export async function registrationVerify(request, reply) { | ||
| const { accountId, credential, name } = request.body || {}; | ||
|
|
||
| if (!accountId || !credential) { |
There was a problem hiding this comment.
Similar to the registration options endpoint, the accountId from request.body is used without authentication. An attacker could register passkeys for arbitrary accounts. This endpoint needs authentication middleware or session validation to ensure the accountId matches the authenticated user.
| accountId: '${accountId}', | ||
| credential: { | ||
| id: credential.id, | ||
| rawId: bufferToBase64url(credential.rawId), | ||
| type: credential.type, | ||
| response: { | ||
| clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), | ||
| attestationObject: bufferToBase64url(credential.response.attestationObject), | ||
| transports: credential.response.getTransports ? credential.response.getTransports() : [] | ||
| } | ||
| }, | ||
| name: detectDeviceName() | ||
| }) | ||
| }); | ||
|
|
||
| const result = await verifyRes.json(); | ||
| if (result.success) { | ||
| // Passkey added, continue to app | ||
| window.location.href = '/idp/interaction/${uid}/passkey-complete?accountId=${accountId}'; |
There was a problem hiding this comment.
The accountId and uid are both embedded directly in JavaScript template literals without escaping, creating XSS vulnerabilities. An attacker who can control these values could inject malicious JavaScript. Use HTML escaping for all dynamic values or pass them through data attributes.
| btn.innerHTML = '${passkeyIcon} Add Passkey'; | ||
| return; | ||
| } | ||
|
|
||
| // Convert base64url to ArrayBuffer | ||
| options.challenge = base64urlToBuffer(options.challenge); | ||
| options.user.id = base64urlToBuffer(options.user.id); | ||
| if (options.excludeCredentials) { | ||
| options.excludeCredentials = options.excludeCredentials.map(c => ({ | ||
| ...c, | ||
| id: base64urlToBuffer(c.id) | ||
| })); | ||
| } | ||
|
|
||
| // Prompt user to create passkey | ||
| const credential = await navigator.credentials.create({ publicKey: options }); | ||
|
|
||
| // Send response to server | ||
| const verifyRes = await fetch('/idp/passkey/register/verify', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| accountId: '${accountId}', | ||
| credential: { | ||
| id: credential.id, | ||
| rawId: bufferToBase64url(credential.rawId), | ||
| type: credential.type, | ||
| response: { | ||
| clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), | ||
| attestationObject: bufferToBase64url(credential.response.attestationObject), | ||
| transports: credential.response.getTransports ? credential.response.getTransports() : [] | ||
| } | ||
| }, | ||
| name: detectDeviceName() | ||
| }) | ||
| }); | ||
|
|
||
| const result = await verifyRes.json(); | ||
| if (result.success) { | ||
| // Passkey added, continue to app | ||
| window.location.href = '/idp/interaction/${uid}/passkey-complete?accountId=${accountId}'; | ||
| } else { | ||
| alert('Failed to add passkey: ' + (result.error || 'Unknown error')); | ||
| btn.disabled = false; | ||
| btn.innerHTML = '${passkeyIcon} Add Passkey'; |
There was a problem hiding this comment.
The passkeyIcon SVG is embedded in multiple button restoration calls without escaping. If an attacker modifies btn.innerHTML to include this unescaped icon variable, it could lead to injection issues. Consider creating the icon as a constant or using safer DOM manipulation methods.
| export async function handlePasskeySkip(request, reply, provider) { | ||
| const { uid } = request.params; | ||
|
|
||
| try { | ||
| const interaction = await provider.Interaction.find(uid); | ||
| if (!interaction) { | ||
| return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.')); | ||
| } | ||
|
|
||
| // Get the pending login result | ||
| const result = interaction.result; | ||
| if (!result?.login?.accountId) { | ||
| return reply.code(400).type('text/html').send(errorPage('Invalid state', 'No pending login found.')); | ||
| } | ||
|
|
||
| // Mark passkey prompt as dismissed so we don't nag again | ||
| await setPasskeyPromptDismissed(result.login.accountId, true); | ||
|
|
||
| request.log.info({ accountId: result.login.accountId, uid }, 'Passkey prompt skipped'); | ||
|
|
||
| // Complete the OIDC interaction | ||
| reply.hijack(); | ||
| return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false }); |
There was a problem hiding this comment.
The handlePasskeySkip function retrieves the interaction result but doesn't validate that the interaction is in the expected state (passkeyPromptPending = true). An attacker could potentially call this endpoint to skip passkey setup even when not in the passkey prompt flow, or manipulate the stored result. Add validation to ensure the interaction is in the correct state before proceeding.
| const bytes = new Uint8Array(buffer); | ||
| let binary = ''; | ||
| for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); | ||
| return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''); |
There was a problem hiding this comment.
The regex pattern has double backslashes (\+, \/) which will match literal backslashes followed by the character, not the + or / characters themselves. In JavaScript template literals, this should be single backslash. Change /\+/g to /+/g and /\//g to ///g for correct base64url encoding.
| const bytes = new Uint8Array(buffer); | ||
| let binary = ''; | ||
| for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); | ||
| return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''); |
There was a problem hiding this comment.
The regex pattern has double backslashes (\+, \/) which will match literal backslashes followed by the character, not the + or / characters themselves. In JavaScript template literals, this should be single backslash. Change /\+/g to /+/g and /\//g to ///g for correct base64url encoding.
| return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''); | |
| return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); |
| const { accountId } = request.body || {}; | ||
|
|
||
| if (!accountId) { | ||
| return reply.code(401).send({ error: 'Must provide accountId' }); | ||
| } | ||
|
|
||
| const account = await accounts.findById(accountId); |
There was a problem hiding this comment.
The accountId is passed in the request body without authentication checks. An attacker could attempt to register passkeys for other users' accounts by providing arbitrary accountIds. The endpoint should verify that the accountId matches the currently authenticated session or require additional authorization.
| const { accountId } = request.body || {}; | |
| if (!accountId) { | |
| return reply.code(401).send({ error: 'Must provide accountId' }); | |
| } | |
| const account = await accounts.findById(accountId); | |
| // Derive the accountId from the authenticated session, not from client input | |
| const sessionAccountId = request.session && request.session.accountId; | |
| const { accountId } = request.body || {}; | |
| if (!sessionAccountId) { | |
| return reply.code(401).send({ error: 'Not authenticated' }); | |
| } | |
| // If a body accountId is provided, ensure it matches the authenticated account | |
| if (accountId && accountId !== sessionAccountId) { | |
| return reply.code(403).send({ error: 'Not authorized for requested account' }); | |
| } | |
| const account = await accounts.findById(sessionAccountId); |
| const optionsRes = await fetch('/idp/passkey/register/options', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ accountId: '${accountId}' }) |
There was a problem hiding this comment.
The accountId is embedded directly in the client-side JavaScript without proper escaping, creating a potential XSS vulnerability. If an accountId contains special characters, quotes, or HTML/JavaScript code, it could break out of the string context and execute arbitrary code. Consider using proper HTML escaping or passing the accountId through a data attribute instead.
| const result = await verifyRes.json(); | ||
| if (result.success) { | ||
| // Complete the OIDC interaction | ||
| window.location.href = '/idp/interaction/${uid}/passkey-complete?accountId=' + result.accountId; |
There was a problem hiding this comment.
The uid variable is embedded in the JavaScript template literal without escaping. If an attacker can control the uid value (e.g., through URL manipulation), they could inject malicious JavaScript code. The uid should be properly escaped or passed through a data attribute.
| window.location.href = '/idp/interaction/${uid}/passkey-complete?accountId=' + result.accountId; | |
| window.location.href = '/idp/interaction/' + ${JSON.stringify(uid)} + '/passkey-complete?accountId=' + result.accountId; |
| // Login options | ||
| fastify.post('/idp/passkey/login/options', async (request, reply) => { |
There was a problem hiding this comment.
The /idp/passkey/login/options endpoint lacks rate limiting. An attacker could spam this endpoint to generate challenges and potentially exhaust memory or perform enumeration attacks. Consider adding rate limiting similar to the verify endpoint.
| // Login options | |
| fastify.post('/idp/passkey/login/options', async (request, reply) => { | |
| // Login options - rate limited | |
| fastify.post('/idp/passkey/login/options', { | |
| config: { | |
| rateLimit: { | |
| max: 10, | |
| timeWindow: '1 minute', | |
| keyGenerator: (request) => request.ip | |
| } | |
| } | |
| }, async (request, reply) => { |
Security fixes: - Add escapeJs() function to prevent XSS in JavaScript template literals - Use encodeURIComponent for URL construction in client-side JS - Add interaction state validation (passkeyPromptPending) to handlePasskeySkip - Add accountId mismatch check in handlePasskeyComplete for post-login flow - Add rate limiting to passkey register/login options endpoints Bug fixes: - Fix base64url regex (use character class instead of escaped chars) - Fix IPv6 hostname parsing using URL API Code quality: - Use request.log.error() instead of console.error() for consistency
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 25 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Store challenge for verification | ||
| challenges.set(account.id, { | ||
| challenge: options.challenge, | ||
| type: 'registration', | ||
| expires: Date.now() + 60000 // 1 minute | ||
| }); |
There was a problem hiding this comment.
The challenge is stored using accountId as the key, which means if the same user initiates multiple registration attempts (e.g., from different tabs or devices), the older challenge will be overwritten. This could lead to race conditions or allow an attacker to invalidate a legitimate registration attempt by starting their own. Consider using a unique random key for each challenge and returning it to the client, similar to how authentication uses challengeKey.
| }); | ||
| } catch (err) { | ||
| request.log.error({ err }, 'Passkey authentication error'); | ||
| return reply.code(400).send({ error: err.message }); |
There was a problem hiding this comment.
Error handling at line 270 exposes the internal error message directly to the client. This could leak sensitive information about the server's internal workings. Consider using a generic error message for the response while logging the detailed error server-side.
| return reply.code(400).send({ error: err.message }); | |
| return reply.code(400).send({ error: 'Authentication failed' }); |
| !fullAccount.passkeyPromptDismissed && | ||
| request.passkeyEnabled !== false; |
There was a problem hiding this comment.
The passkey prompt condition checks request.passkeyEnabled which is not a standard HTTP request property. This property is not set anywhere in the provided code, so this condition will always evaluate to undefined !== false, which is true. This means passkeys can't be disabled via this mechanism. If this is intended to be a feature flag, it should be properly documented and initialized, or this check should be removed.
| !fullAccount.passkeyPromptDismissed && | |
| request.passkeyEnabled !== false; | |
| !fullAccount.passkeyPromptDismissed; |
| export async function updatePasskeyCounter(accountId, credentialId, newCounter) { | ||
| const account = await findById(accountId); | ||
| if (!account || !account.passkeys) return; | ||
|
|
||
| const passkey = account.passkeys.find(p => p.credentialId === credentialId); | ||
| if (passkey) { | ||
| passkey.counter = newCounter; | ||
| passkey.lastUsed = new Date().toISOString(); | ||
| await saveAccount(account); | ||
| } |
There was a problem hiding this comment.
Similar to the addPasskey function, there's a potential race condition here. Between reading the account at line 352 and saving it at line 359, another concurrent operation could modify the account. This could result in lost updates or incorrect counter values. Consider implementing optimistic locking or ensuring atomic updates for critical passkey operations like counter updates which are important for security (preventing replay attacks).
| export async function updatePasskeyCounter(accountId, credentialId, newCounter) { | |
| const account = await findById(accountId); | |
| if (!account || !account.passkeys) return; | |
| const passkey = account.passkeys.find(p => p.credentialId === credentialId); | |
| if (passkey) { | |
| passkey.counter = newCounter; | |
| passkey.lastUsed = new Date().toISOString(); | |
| await saveAccount(account); | |
| } | |
| const accountLocks = new Map(); | |
| async function withAccountLock(accountId, fn) { | |
| const previous = accountLocks.get(accountId) || Promise.resolve(); | |
| let currentPromise = previous.then(() => fn()); | |
| // Ensure that we clean up the lock when the operation completes | |
| currentPromise = currentPromise.finally(() => { | |
| if (accountLocks.get(accountId) === currentPromise) { | |
| accountLocks.delete(accountId); | |
| } | |
| }); | |
| accountLocks.set(accountId, currentPromise); | |
| return currentPromise; | |
| } | |
| export async function updatePasskeyCounter(accountId, credentialId, newCounter) { | |
| await withAccountLock(accountId, async () => { | |
| const account = await findById(accountId); | |
| if (!account || !account.passkeys) return; | |
| const passkey = account.passkeys.find(p => p.credentialId === credentialId); | |
| if (passkey) { | |
| passkey.counter = newCounter; | |
| passkey.lastUsed = new Date().toISOString(); | |
| await saveAccount(account); | |
| } | |
| }); |
| account.passkeys = account.passkeys || []; | ||
| account.passkeys.push({ | ||
| credentialId: credential.credentialId, | ||
| publicKey: credential.publicKey, | ||
| counter: credential.counter || 0, | ||
| transports: credential.transports || [], | ||
| createdAt: new Date().toISOString(), | ||
| lastUsed: null, | ||
| name: credential.name || 'Security Key' | ||
| }); | ||
|
|
There was a problem hiding this comment.
The addPasskey function doesn't check if a passkey with the same credentialId already exists before adding it. While the excludeCredentials in the registration options should prevent this in normal flows, a malicious client could potentially bypass client-side checks and register duplicate credentials. Consider adding a check to ensure the credentialId doesn't already exist in the account's passkeys array before adding it.
| var INTERACTION_UID = '${safeUid}'; | ||
|
|
||
| async function loginWithPasskey() { | ||
| try { | ||
| // Get authentication options | ||
| const optionsRes = await fetch('/idp/passkey/login/options', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ visitorId: crypto.randomUUID() }) | ||
| }); | ||
| const options = await optionsRes.json(); | ||
| if (options.error) { | ||
| alert('Error: ' + options.error); | ||
| return; | ||
| } | ||
|
|
||
| // Convert base64url to ArrayBuffer | ||
| options.challenge = base64urlToBuffer(options.challenge); | ||
| if (options.allowCredentials) { | ||
| options.allowCredentials = options.allowCredentials.map(c => ({ | ||
| ...c, | ||
| id: base64urlToBuffer(c.id) | ||
| })); | ||
| } | ||
|
|
||
| // Prompt user for passkey | ||
| const credential = await navigator.credentials.get({ publicKey: options }); | ||
|
|
||
| // Send response to server | ||
| const verifyRes = await fetch('/idp/passkey/login/verify', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| challengeKey: options.challengeKey, | ||
| credential: { | ||
| id: credential.id, | ||
| rawId: bufferToBase64url(credential.rawId), | ||
| type: credential.type, | ||
| response: { | ||
| clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), | ||
| authenticatorData: bufferToBase64url(credential.response.authenticatorData), | ||
| signature: bufferToBase64url(credential.response.signature), | ||
| userHandle: credential.response.userHandle | ||
| ? bufferToBase64url(credential.response.userHandle) | ||
| : null | ||
| } | ||
| } | ||
| }) | ||
| }); | ||
|
|
||
| const result = await verifyRes.json(); | ||
| if (result.success) { | ||
| // Complete the OIDC interaction - build URL safely | ||
| const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId); | ||
| window.location.href = redirectUrl; | ||
| } else { | ||
| alert('Passkey authentication failed: ' + (result.error || 'Unknown error')); | ||
| } | ||
| } catch (err) { | ||
| if (err.name === 'NotAllowedError') { | ||
| // User cancelled - do nothing | ||
| } else { | ||
| console.error('Passkey error:', err); | ||
| alert('Passkey authentication failed: ' + err.message); | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
The client-side JavaScript exposes the INTERACTION_UID in a global variable. While the UID is already part of the URL and not particularly sensitive, using a more scoped approach (e.g., an IIFE or module pattern) would be better practice to avoid polluting the global namespace and potential conflicts with other scripts.
| var INTERACTION_UID = '${safeUid}'; | |
| async function loginWithPasskey() { | |
| try { | |
| // Get authentication options | |
| const optionsRes = await fetch('/idp/passkey/login/options', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ visitorId: crypto.randomUUID() }) | |
| }); | |
| const options = await optionsRes.json(); | |
| if (options.error) { | |
| alert('Error: ' + options.error); | |
| return; | |
| } | |
| // Convert base64url to ArrayBuffer | |
| options.challenge = base64urlToBuffer(options.challenge); | |
| if (options.allowCredentials) { | |
| options.allowCredentials = options.allowCredentials.map(c => ({ | |
| ...c, | |
| id: base64urlToBuffer(c.id) | |
| })); | |
| } | |
| // Prompt user for passkey | |
| const credential = await navigator.credentials.get({ publicKey: options }); | |
| // Send response to server | |
| const verifyRes = await fetch('/idp/passkey/login/verify', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| challengeKey: options.challengeKey, | |
| credential: { | |
| id: credential.id, | |
| rawId: bufferToBase64url(credential.rawId), | |
| type: credential.type, | |
| response: { | |
| clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), | |
| authenticatorData: bufferToBase64url(credential.response.authenticatorData), | |
| signature: bufferToBase64url(credential.response.signature), | |
| userHandle: credential.response.userHandle | |
| ? bufferToBase64url(credential.response.userHandle) | |
| : null | |
| } | |
| } | |
| }) | |
| }); | |
| const result = await verifyRes.json(); | |
| if (result.success) { | |
| // Complete the OIDC interaction - build URL safely | |
| const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId); | |
| window.location.href = redirectUrl; | |
| } else { | |
| alert('Passkey authentication failed: ' + (result.error || 'Unknown error')); | |
| } | |
| } catch (err) { | |
| if (err.name === 'NotAllowedError') { | |
| // User cancelled - do nothing | |
| } else { | |
| console.error('Passkey error:', err); | |
| alert('Passkey authentication failed: ' + err.message); | |
| } | |
| } | |
| } | |
| (function () { | |
| const interactionUid = '${safeUid}'; | |
| async function loginWithPasskey() { | |
| try { | |
| // Get authentication options | |
| const optionsRes = await fetch('/idp/passkey/login/options', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ visitorId: crypto.randomUUID() }) | |
| }); | |
| const options = await optionsRes.json(); | |
| if (options.error) { | |
| alert('Error: ' + options.error); | |
| return; | |
| } | |
| // Convert base64url to ArrayBuffer | |
| options.challenge = base64urlToBuffer(options.challenge); | |
| if (options.allowCredentials) { | |
| options.allowCredentials = options.allowCredentials.map(c => ({ | |
| ...c, | |
| id: base64urlToBuffer(c.id) | |
| })); | |
| } | |
| // Prompt user for passkey | |
| const credential = await navigator.credentials.get({ publicKey: options }); | |
| // Send response to server | |
| const verifyRes = await fetch('/idp/passkey/login/verify', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| challengeKey: options.challengeKey, | |
| credential: { | |
| id: credential.id, | |
| rawId: bufferToBase64url(credential.rawId), | |
| type: credential.type, | |
| response: { | |
| clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), | |
| authenticatorData: bufferToBase64url(credential.response.authenticatorData), | |
| signature: bufferToBase64url(credential.response.signature), | |
| userHandle: credential.response.userHandle | |
| ? bufferToBase64url(credential.response.userHandle) | |
| : null | |
| } | |
| } | |
| }) | |
| }); | |
| const result = await verifyRes.json(); | |
| if (result.success) { | |
| // Complete the OIDC interaction - build URL safely | |
| const redirectUrl = '/idp/interaction/' + encodeURIComponent(interactionUid) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId); | |
| window.location.href = redirectUrl; | |
| } else { | |
| alert('Passkey authentication failed: ' + (result.error || 'Unknown error')); | |
| } | |
| } catch (err) { | |
| if (err.name === 'NotAllowedError') { | |
| // User cancelled - do nothing | |
| } else { | |
| console.error('Passkey error:', err); | |
| alert('Passkey authentication failed: ' + err.message); | |
| } | |
| } | |
| } | |
| // Expose only the click handler globally for the inline onclick attribute | |
| window.loginWithPasskey = loginWithPasskey; | |
| })(); |
| const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId); | ||
| window.location.href = redirectUrl; | ||
| } else { | ||
| alert('Passkey authentication failed: ' + (result.error || 'Unknown error')); |
There was a problem hiding this comment.
The error message from the server is directly interpolated into the alert without sanitization. If the server returns an error message containing special characters or potentially malicious content, it could be displayed in an unsafe way. While alert() is generally safe from XSS, it's better practice to sanitize or limit the error messages displayed to users.
| alert('Passkey authentication failed: ' + (result.error || 'Unknown error')); | |
| console.error('Passkey authentication failed:', result.error || 'Unknown error'); | |
| alert('Passkey authentication failed. Please try again or use a different sign-in method.'); |
| export async function addPasskey(accountId, credential) { | ||
| const account = await findById(accountId); | ||
| if (!account) return false; | ||
|
|
||
| account.passkeys = account.passkeys || []; | ||
| account.passkeys.push({ | ||
| credentialId: credential.credentialId, | ||
| publicKey: credential.publicKey, | ||
| counter: credential.counter || 0, | ||
| transports: credential.transports || [], | ||
| createdAt: new Date().toISOString(), | ||
| lastUsed: null, | ||
| name: credential.name || 'Security Key' | ||
| }); | ||
|
|
||
| await saveAccount(account); |
There was a problem hiding this comment.
There's a potential race condition in the passkey registration flow. Between reading the account at line 309 and saving it at line 323, another concurrent request could modify the same account (e.g., another passkey registration). This could result in lost updates where one passkey overwrites another. Consider implementing optimistic locking or using a transaction/lock mechanism to prevent concurrent modifications to the same account.
| // Temporary challenge storage (in-memory, cleared on restart) | ||
| // For production clusters, use Redis or session storage |
There was a problem hiding this comment.
The comment states that in-memory challenge storage is cleared on restart and suggests using Redis or session storage for production clusters. However, there's no documentation or configuration option provided to actually use an alternative storage mechanism. For a production-ready feature, either implement the Redis/session storage option or document clearly that this is a single-instance limitation and won't work in clustered deployments.
| // Temporary challenge storage (in-memory, cleared on restart) | |
| // For production clusters, use Redis or session storage | |
| // Temporary challenge storage (in-memory, per-process). | |
| // NOTE: This is cleared on restart and is not shared between instances, | |
| // so it is only suitable for single-instance / non-clustered deployments. | |
| // For production clusters, you must replace this with a shared store | |
| // (for example, Redis or session storage) and wire it in explicitly. |
| export async function registrationVerify(request, reply) { | ||
| const { accountId, credential, name } = request.body || {}; | ||
|
|
||
| if (!accountId || !credential) { | ||
| return reply.code(400).send({ error: 'Missing accountId or credential' }); | ||
| } |
There was a problem hiding this comment.
The registrationVerify endpoint accepts accountId from the request body without verifying the request is authenticated or that the requester has permission to complete passkey registration for that account. This allows an attacker to complete passkey registration for any account by providing a valid credential from their own device. The endpoint should verify that the user is authenticated and authorized to add a passkey to the specified account.
- Add size limit (10K) to in-memory challenges Map to prevent DoS - Use generic error messages instead of exposing internal errors - Remove unused request.passkeyEnabled check - Add duplicate credentialId check in addPasskey
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const verification = await verifyRegistrationResponse({ | ||
| response: credential, | ||
| expectedChallenge: stored.challenge, | ||
| expectedOrigin: getOrigin(request), | ||
| expectedRPID: rp.id | ||
| }); |
There was a problem hiding this comment.
The credential object from request.body is passed directly to verifyRegistrationResponse without validating its structure. While @simplewebauthn/server likely performs its own validation, it's good practice to validate that the credential object has the expected shape before processing to fail fast on malformed requests.
| export async function registrationVerify(request, reply) { | ||
| const { accountId, credential, name } = request.body || {}; | ||
|
|
||
| if (!accountId || !credential) { | ||
| return reply.code(400).send({ error: 'Missing accountId or credential' }); | ||
| } | ||
|
|
||
| const account = await accounts.findById(accountId); | ||
| if (!account) { | ||
| return reply.code(404).send({ error: 'Account not found' }); | ||
| } |
There was a problem hiding this comment.
Similar to the registration options endpoint, the registration verify endpoint does not validate that the user is authenticated or that they own the account they're registering a passkey for. This allows an attacker to add a passkey to any account if they know the accountId.
| function storeChallenge(key, value) { | ||
| // If at capacity, remove oldest expired entries first | ||
| if (challenges.size >= MAX_CHALLENGES) { | ||
| const now = Date.now(); | ||
| for (const [k, v] of challenges.entries()) { | ||
| if (now > v.expires) { | ||
| challenges.delete(k); | ||
| } | ||
| if (challenges.size < MAX_CHALLENGES) break; | ||
| } | ||
| } | ||
| // If still at capacity, reject (DoS protection) | ||
| if (challenges.size >= MAX_CHALLENGES) { | ||
| return false; | ||
| } | ||
| challenges.set(key, value); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
There's a potential race condition in the challenge cleanup logic. If multiple requests try to store challenges while at capacity, the cleanup loop might not fully clean up before the second request checks the size, potentially causing valid requests to be rejected unnecessarily. While this is unlikely to be a major issue in practice, consider implementing a more robust cleanup strategy or using a LRU cache.
|
|
||
| if (!accountId) { | ||
| return reply.code(401).send({ error: 'Must provide accountId' }); | ||
| } | ||
|
|
||
| const account = await accounts.findById(accountId); |
There was a problem hiding this comment.
The registration endpoints do not validate that the user is authenticated before allowing passkey registration. An attacker could potentially register a passkey for any accountId they know, even if they don't control that account. The endpoints should verify that the request comes from an authenticated session and that the session's accountId matches the requested accountId.
| if (!accountId) { | |
| return reply.code(401).send({ error: 'Must provide accountId' }); | |
| } | |
| const account = await accounts.findById(accountId); | |
| const sessionAccountId = request.session && request.session.accountId; | |
| // Require an authenticated session | |
| if (!sessionAccountId) { | |
| return reply.code(401).send({ error: 'Authentication required' }); | |
| } | |
| // If a body accountId is provided, it must match the authenticated user | |
| if (accountId && accountId !== sessionAccountId) { | |
| return reply.code(403).send({ error: 'Cannot register passkey for a different account' }); | |
| } | |
| const resolvedAccountId = accountId || sessionAccountId; | |
| const account = await accounts.findById(resolvedAccountId); |
| /** | ||
| * Get Relying Party configuration from request | ||
| * Handles both IPv4 (with port) and IPv6 addresses correctly | ||
| */ | ||
| function getRP(request) { | ||
| let hostname; | ||
| try { | ||
| // Use URL parsing to correctly extract hostname (handles IPv6) | ||
| const url = new URL(`${request.protocol}://${request.hostname}`); | ||
| hostname = url.hostname; | ||
| } catch { | ||
| // Fallback: strip port from hostname (IPv4 only) | ||
| hostname = String(request.hostname || '').split(':')[0]; | ||
| } | ||
| return { | ||
| name: 'Solid Pod', | ||
| id: hostname | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Get origin from request | ||
| */ | ||
| function getOrigin(request) { | ||
| return `${request.protocol}://${request.hostname}`; | ||
| } |
There was a problem hiding this comment.
The origin and RP ID extraction do not respect proxy headers like x-forwarded-proto and x-forwarded-host. When the server is behind a reverse proxy (common in production), request.protocol and request.hostname may not reflect the actual public-facing URL. This could cause WebAuthn verification to fail or, worse, allow credentials to be registered for the wrong origin. The code should check x-forwarded-proto and x-forwarded-host headers, similar to how other parts of the codebase handle this.
| function base64urlToBuffer(base64url) { | ||
| const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); | ||
| const padLen = (4 - base64.length % 4) % 4; | ||
| const padded = base64 + '='.repeat(padLen); | ||
| const binary = atob(padded); | ||
| const bytes = new Uint8Array(binary.length); | ||
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | ||
| return bytes.buffer; | ||
| } | ||
|
|
||
| function bufferToBase64url(buffer) { | ||
| const bytes = new Uint8Array(buffer); | ||
| let binary = ''; | ||
| for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); | ||
| return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, ''); | ||
| } |
There was a problem hiding this comment.
The base64url conversion functions are duplicated across the login page and passkey prompt page. Consider extracting these utility functions into a shared client-side library or defining them once at the top level to improve maintainability.
| const stored = challenges.get(account.id); | ||
| if (!stored || stored.type !== 'registration' || Date.now() > stored.expires) { | ||
| return reply.code(400).send({ error: 'Challenge expired or invalid' }); |
There was a problem hiding this comment.
The challenge validation checks both expiration and type separately, which could create a timing side-channel that reveals whether a challenge exists. While this is a minor issue, consider using a constant-time comparison or checking all conditions together to avoid potential timing attacks that could be used to enumerate valid challenge keys.
- Use random UUID as challengeKey for registration (prevents race conditions when same user registers from multiple tabs) - Validate accountId matches the challenge's accountId - Add logging for verification failures - Client-side JS now sends challengeKey with verify request
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Fallback: strip port from hostname (IPv4 only) | ||
| hostname = String(request.hostname || '').split(':')[0]; |
There was a problem hiding this comment.
The hostname parsing in the getRP function has a fallback that only handles IPv4 by splitting on ':'. However, IPv6 addresses contain colons (e.g., [::1]:3000), so this fallback would incorrectly parse IPv6 addresses. The try-catch should be sufficient if the URL constructor handles all cases, but if it fails for IPv6, the fallback will produce incorrect results. Consider testing with IPv6 or improving the fallback logic.
| // Fallback: strip port from hostname (IPv4 only) | |
| hostname = String(request.hostname || '').split(':')[0]; | |
| // Fallback parsing if URL construction fails | |
| const raw = String(request.hostname || ''); | |
| if (raw.startsWith('[')) { | |
| // Bracketed IPv6 with optional port, e.g. "[::1]:3000" | |
| const closingBracketIndex = raw.indexOf(']'); | |
| if (closingBracketIndex !== -1) { | |
| hostname = raw.slice(1, closingBracketIndex); | |
| } else { | |
| // Malformed but attempt to strip leading bracket | |
| hostname = raw.slice(1); | |
| } | |
| } else { | |
| const colonMatches = raw.match(/:/g) || []; | |
| const colonCount = colonMatches.length; | |
| if (colonCount === 0) { | |
| // Plain hostname (no port) | |
| hostname = raw; | |
| } else if (colonCount === 1) { | |
| // IPv4 or hostname with port, e.g. "example.com:3000" or "127.0.0.1:3000" | |
| hostname = raw.split(':')[0]; | |
| } else { | |
| // Likely bare IPv6 literal without brackets; keep as-is | |
| hostname = raw; | |
| } | |
| } |
| btn.innerHTML = PASSKEY_ICON + ' Add Passkey'; | ||
| return; | ||
| } | ||
|
|
||
| // Save challengeKey for verification | ||
| const challengeKey = options.challengeKey; | ||
|
|
||
| // Convert base64url to ArrayBuffer | ||
| options.challenge = base64urlToBuffer(options.challenge); | ||
| options.user.id = base64urlToBuffer(options.user.id); | ||
| if (options.excludeCredentials) { | ||
| options.excludeCredentials = options.excludeCredentials.map(c => ({ | ||
| ...c, | ||
| id: base64urlToBuffer(c.id) | ||
| })); | ||
| } | ||
|
|
||
| // Prompt user to create passkey | ||
| const credential = await navigator.credentials.create({ publicKey: options }); | ||
|
|
||
| // Send response to server | ||
| const verifyRes = await fetch('/idp/passkey/register/verify', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| accountId: ACCOUNT_ID, | ||
| challengeKey: challengeKey, | ||
| credential: { | ||
| id: credential.id, | ||
| rawId: bufferToBase64url(credential.rawId), | ||
| type: credential.type, | ||
| response: { | ||
| clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), | ||
| attestationObject: bufferToBase64url(credential.response.attestationObject), | ||
| transports: credential.response.getTransports ? credential.response.getTransports() : [] | ||
| } | ||
| }, | ||
| name: detectDeviceName() | ||
| }) | ||
| }); | ||
|
|
||
| const result = await verifyRes.json(); | ||
| if (result.success) { | ||
| // Passkey added, continue to app - build URL safely | ||
| const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(ACCOUNT_ID); | ||
| window.location.href = redirectUrl; | ||
| } else { | ||
| alert('Failed to add passkey: ' + (result.error || 'Unknown error')); | ||
| btn.disabled = false; | ||
| btn.innerHTML = PASSKEY_ICON + ' Add Passkey'; |
There was a problem hiding this comment.
The PASSKEY_ICON variable is being assigned with innerHTML on line 567 and 616. While the icon is a static SVG, this pattern could be risky if the variable content changes in the future. Consider using textContent or creating the SVG via DOM manipulation instead of innerHTML to follow security best practices.
| const bytes = new Uint8Array(binary.length); | ||
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | ||
| return bytes.buffer; | ||
| } | ||
|
|
||
| function bufferToBase64url(buffer) { | ||
| const bytes = new Uint8Array(buffer); | ||
| let binary = ''; | ||
| for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); | ||
| return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, ''); |
There was a problem hiding this comment.
The base64url conversion functions use manual loop iteration with String.fromCharCode which can be inefficient for large buffers. While this works for WebAuthn credential data (typically small), consider using TextDecoder for better performance and readability where appropriate.
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | |
| return bytes.buffer; | |
| } | |
| function bufferToBase64url(buffer) { | |
| const bytes = new Uint8Array(buffer); | |
| let binary = ''; | |
| for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); | |
| return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, ''); | |
| const bytes = Uint8Array.from(binary, c => c.charCodeAt(0)); | |
| return bytes.buffer; | |
| } | |
| function bufferToBase64url(buffer) { | |
| const bytes = new Uint8Array(buffer); | |
| const binary = new TextDecoder('iso-8859-1').decode(bytes); | |
| return btoa(binary) | |
| .replace(/[+]/g, '-') | |
| .replace(/[/]/g, '_') | |
| .replace(/=/g, ''); |
|
|
||
| // Detect if this is a browser (wants HTML/redirect) or programmatic client (wants JSON) | ||
| const acceptHeader = request.headers.accept || ''; | ||
| const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json'); |
There was a problem hiding this comment.
The wantsBrowserRedirect detection checks for text/html in the Accept header, but some API clients may send text/html along with application/json. The logic correctly handles this by also checking that application/json is not present, but this could still be fragile. Consider also checking the request method or adding a more explicit client type detection mechanism (e.g., a custom header or user agent check) for better reliability.
| const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json'); | |
| const userAgent = request.headers['user-agent'] || ''; | |
| const isBrowserUserAgent = /Mozilla|Chrome|Safari|Firefox|MSIE|Trident/i.test(userAgent); | |
| const wantsBrowserRedirect = isBrowserUserAgent && | |
| acceptHeader.includes('text/html') && | |
| !acceptHeader.includes('application/json'); |
| if (shouldPromptPasskey) { | ||
| // Show passkey registration prompt before completing login | ||
| // Store the pending login in the interaction | ||
| interaction.result = { | ||
| login: { accountId: account.id, remember: true } | ||
| }; | ||
| interaction.passkeyPromptPending = true; | ||
| await interaction.save(interaction.exp - Math.floor(Date.now() / 1000)); |
There was a problem hiding this comment.
The passkey prompt flow stores the result in interaction.result and sets interaction.passkeyPromptPending = true, but there's no validation that interaction.result doesn't already exist from a previous login. If the interaction already has a result, this could potentially overwrite it. Consider checking if interaction.result already exists and handling that case appropriately.
| await saveAccount(account); | ||
|
|
||
| // Update credential index | ||
| const credentialIndex = await loadIndex(getCredentialIndexPath()); | ||
| credentialIndex[credential.credentialId] = accountId; | ||
| await saveIndex(getCredentialIndexPath(), credentialIndex); |
There was a problem hiding this comment.
The addPasskey function updates the credential index after saving the account. If the saveIndex call fails but saveAccount succeeds, the account will have the passkey but the index won't be updated. This could lead to the credential not being findable via findByCredentialId. Consider using a transaction-like pattern or handling this failure scenario explicitly to maintain data consistency.
| await saveAccount(account); | |
| // Update credential index | |
| const credentialIndex = await loadIndex(getCredentialIndexPath()); | |
| credentialIndex[credential.credentialId] = accountId; | |
| await saveIndex(getCredentialIndexPath(), credentialIndex); | |
| // First, persist the updated account with the new passkey | |
| try { | |
| await saveAccount(account); | |
| } catch { | |
| // If saving the account fails, the passkey is not stored; report failure | |
| return false; | |
| } | |
| // Then, update credential index. If this fails, roll back the passkey on the account. | |
| try { | |
| const credentialIndex = await loadIndex(getCredentialIndexPath()); | |
| credentialIndex[credential.credentialId] = accountId; | |
| await saveIndex(getCredentialIndexPath(), credentialIndex); | |
| } catch { | |
| // Roll back the passkey addition on the account to maintain consistency | |
| account.passkeys = account.passkeys.filter( | |
| pk => pk.credentialId !== credential.credentialId | |
| ); | |
| try { | |
| await saveAccount(account); | |
| } catch { | |
| // If rollback fails, we still report failure; the system may require manual repair | |
| } | |
| return false; | |
| } |
| account.passkeys.splice(index, 1); | ||
| await saveAccount(account); | ||
|
|
||
| // Update credential index | ||
| const credentialIndex = await loadIndex(getCredentialIndexPath()); | ||
| delete credentialIndex[credentialId]; | ||
| await saveIndex(getCredentialIndexPath(), credentialIndex); | ||
|
|
||
| return true; |
There was a problem hiding this comment.
Similar to addPasskey, the removePasskey function has the same consistency issue - if saveIndex fails after saveAccount succeeds, the account will have the passkey removed but the index will still point to it. This could lead to stale index entries.
| account.passkeys.splice(index, 1); | |
| await saveAccount(account); | |
| // Update credential index | |
| const credentialIndex = await loadIndex(getCredentialIndexPath()); | |
| delete credentialIndex[credentialId]; | |
| await saveIndex(getCredentialIndexPath(), credentialIndex); | |
| return true; | |
| const credentialIndexPath = getCredentialIndexPath(); | |
| let removedPasskey; | |
| try { | |
| // Remove passkey from account and persist | |
| removedPasskey = account.passkeys.splice(index, 1)[0]; | |
| await saveAccount(account); | |
| // Update credential index | |
| const credentialIndex = await loadIndex(credentialIndexPath); | |
| delete credentialIndex[credentialId]; | |
| await saveIndex(credentialIndexPath, credentialIndex); | |
| return true; | |
| } catch (err) { | |
| // Best-effort rollback: restore passkey on the account if we removed it | |
| if (removedPasskey && !account.passkeys.find(p => p.credentialId === credentialId)) { | |
| account.passkeys.push(removedPasskey); | |
| try { | |
| await saveAccount(account); | |
| } catch { | |
| // If rollback fails, we still rethrow the original error | |
| } | |
| } | |
| throw err; | |
| } |
| }); | ||
|
|
||
| // Passkey interaction handlers | ||
| fastify.get('/idp/interaction/:uid/passkey-complete', async (request, reply) => { |
There was a problem hiding this comment.
The /idp/interaction/:uid/passkey-complete endpoint is not rate-limited, which could be exploited for brute-force attacks trying different accountId values. Consider adding rate limiting to this endpoint similar to the other passkey endpoints.
| fastify.get('/idp/interaction/:uid/passkey-complete', async (request, reply) => { | |
| fastify.get('/idp/interaction/:uid/passkey-complete', { | |
| config: { | |
| rateLimit: { | |
| max: 10, | |
| timeWindow: '1 minute', | |
| keyGenerator: (request) => request.ip | |
| } | |
| } | |
| }, async (request, reply) => { |
| if (now > v.expires) { | ||
| challenges.delete(k); | ||
| } | ||
| if (challenges.size < MAX_CHALLENGES) break; |
There was a problem hiding this comment.
In the storeChallenge function, when at capacity and removing expired entries, the loop breaks early if challenges.size < MAX_CHALLENGES. This means it might not remove all expired entries, leaving stale data in the map. Consider continuing to remove all expired entries regardless of size, or at least document this behavior.
| if (challenges.size < MAX_CHALLENGES) break; |
Summary
Implements passwordless authentication using passkeys (WebAuthn/FIDO2):
Changes
package.json@simplewebauthn/serverdependencysrc/idp/accounts.jssrc/idp/passkey.jssrc/idp/views.jssrc/idp/interactions.jssrc/idp/index.jsUser Flow
First Login (with password)
Subsequent Logins (with passkey)
Screenshots
Login page with passkey button:
Post-login passkey prompt:
Test Plan
Phase
This implements Phase 1 (post-login prompt + passkey login) of #78.
Future phases: