Skip to content

feat: add passkey (WebAuthn) authentication support - #79

Merged
melvincarvalho merged 5 commits into
gh-pagesfrom
feature/passkey-auth
Jan 11, 2026
Merged

feat: add passkey (WebAuthn) authentication support#79
melvincarvalho merged 5 commits into
gh-pagesfrom
feature/passkey-auth

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Summary

Implements passwordless authentication using passkeys (WebAuthn/FIDO2):

  • Login page now shows "Sign in with Passkey" button
  • After password login, users see a prompt to add a passkey
  • Passkeys use Touch ID, Face ID, or security keys

Changes

File Change
package.json Add @simplewebauthn/server dependency
src/idp/accounts.js Add passkey storage methods and credential index
src/idp/passkey.js New WebAuthn registration/authentication endpoints
src/idp/views.js Add passkey button to login, add passkey prompt page
src/idp/interactions.js Add passkey-complete and passkey-skip handlers
src/idp/index.js Register passkey routes

User Flow

First Login (with password)

Login page -> Enter password -> "Add a Passkey?" prompt -> Touch ID -> Done

Subsequent Logins (with passkey)

Login page -> Click "Sign in with Passkey" -> Touch ID -> Done

Screenshots

Login page with passkey button:

┌────────────────────────────────┐
│  [🔑 Sign in with Passkey]     │
│  ─────────── or ───────────   │
│  Username: [____________]      │
│  Password: [____________]      │
│  [        Sign In         ]    │
└────────────────────────────────┘

Post-login passkey prompt:

┌────────────────────────────────┐
│  Add a Passkey?                │
│  Sign in faster next time      │
│  [  Add Passkey  ]  [  Skip  ] │
└────────────────────────────────┘

Test Plan

  • All existing IdP tests pass
  • All auth tests pass
  • Manual test: login with password, add passkey
  • Manual test: login with passkey
  • Manual test: skip passkey prompt

Phase

This implements Phase 1 (post-login prompt + passkey login) of #78.

Future phases:

  • Phase 2: Account settings page for passkey management
  • Phase 3: Passkey option during registration
  • Phase 4: Passwordless accounts

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/passkey.js Outdated
Comment on lines +97 to +102
* Verify and store the registration response
*/
export async function registrationVerify(request, reply) {
const { accountId, credential, name } = request.body || {};

if (!accountId || !credential) {

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js Outdated
Comment on lines +561 to +579
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}';

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js Outdated
Comment on lines +539 to +583
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';

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/interactions.js
Comment on lines +504 to +526
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 });

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js Outdated
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, '');

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js Outdated
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, '');

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +54 to +60
const { accountId } = request.body || {};

if (!accountId) {
return reply.code(401).send({ error: 'Must provide accountId' });
}

const account = await accounts.findById(accountId);

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js Outdated
const optionsRes = await fetch('/idp/passkey/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountId: '${accountId}' })

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js Outdated
const result = await verifyRes.json();
if (result.success) {
// Complete the OIDC interaction
window.location.href = '/idp/interaction/${uid}/passkey-complete?accountId=' + result.accountId;

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
window.location.href = '/idp/interaction/${uid}/passkey-complete?accountId=' + result.accountId;
window.location.href = '/idp/interaction/' + ${JSON.stringify(uid)} + '/passkey-complete?accountId=' + result.accountId;

Copilot uses AI. Check for mistakes.
Comment thread src/idp/index.js Outdated
Comment on lines +315 to +316
// Login options
fastify.post('/idp/passkey/login/options', async (request, reply) => {

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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) => {

Copilot uses AI. Check for mistakes.
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/passkey.js Outdated
Comment on lines +94 to +99
// Store challenge for verification
challenges.set(account.id, {
challenge: options.challenge,
type: 'registration',
expires: Date.now() + 60000 // 1 minute
});

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js Outdated
});
} catch (err) {
request.log.error({ err }, 'Passkey authentication error');
return reply.code(400).send({ error: err.message });

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return reply.code(400).send({ error: err.message });
return reply.code(400).send({ error: 'Authentication failed' });

Copilot uses AI. Check for mistakes.
Comment thread src/idp/interactions.js Outdated
Comment on lines +136 to +137
!fullAccount.passkeyPromptDismissed &&
request.passkeyEnabled !== false;

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
!fullAccount.passkeyPromptDismissed &&
request.passkeyEnabled !== false;
!fullAccount.passkeyPromptDismissed;

Copilot uses AI. Check for mistakes.
Comment thread src/idp/accounts.js
Comment on lines +351 to +360
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);
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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);
}
});

Copilot uses AI. Check for mistakes.
Comment thread src/idp/accounts.js
Comment on lines +312 to +322
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'
});

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js
Comment on lines +231 to +298
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);
}
}
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
})();

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js
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'));

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.');

Copilot uses AI. Check for mistakes.
Comment thread src/idp/accounts.js
Comment on lines +308 to +323
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);

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +15 to +16
// Temporary challenge storage (in-memory, cleared on restart)
// For production clusters, use Redis or session storage

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +108 to +113
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' });
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/passkey.js
Comment on lines +155 to +160
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: stored.challenge,
expectedOrigin: getOrigin(request),
expectedRPID: rp.id
});

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +135 to +145
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' });
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +35 to +52
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;
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +87 to +92

if (!accountId) {
return reply.code(401).send({ error: 'Must provide accountId' });
}

const account = await accounts.findById(accountId);

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
Comment on lines +54 to +79
/**
* 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}`;
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js
Comment on lines +637 to +652
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, '');
}

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js Outdated
Comment on lines +147 to +149
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' });

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/idp/passkey.js
Comment on lines +65 to +66
// Fallback: strip port from hostname (IPv4 only)
hostname = String(request.hostname || '').split(':')[0];

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;
}
}

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js
Comment on lines +567 to +616
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';

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/views.js
Comment on lines +304 to +313
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, '');

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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, '');

Copilot uses AI. Check for mistakes.
Comment thread src/idp/interactions.js

// 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');

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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');

Copilot uses AI. Check for mistakes.
Comment thread src/idp/interactions.js
Comment on lines +138 to +145
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));

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/idp/accounts.js
Comment on lines +330 to +335
await saveAccount(account);

// Update credential index
const credentialIndex = await loadIndex(getCredentialIndexPath());
credentialIndex[credential.credentialId] = accountId;
await saveIndex(getCredentialIndexPath(), credentialIndex);

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment thread src/idp/accounts.js
Comment on lines +383 to +391
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;

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment thread src/idp/index.js
});

// Passkey interaction handlers
fastify.get('/idp/interaction/:uid/passkey-complete', async (request, reply) => {

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) => {

Copilot uses AI. Check for mistakes.
Comment thread src/idp/passkey.js
if (now > v.expires) {
challenges.delete(k);
}
if (challenges.size < MAX_CHALLENGES) break;

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (challenges.size < MAX_CHALLENGES) break;

Copilot uses AI. Check for mistakes.
@melvincarvalho
melvincarvalho merged commit 871ff26 into gh-pages Jan 11, 2026
6 checks passed
@melvincarvalho
melvincarvalho deleted the feature/passkey-auth branch January 11, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants