Skip to content

feat: add passkey-first authentication and account passkey management#154

Merged
shuaiplus merged 2 commits intomainfrom
codex/add-passkey-support-for-login-qxdzoe
Mar 30, 2026
Merged

feat: add passkey-first authentication and account passkey management#154
shuaiplus merged 2 commits intomainfrom
codex/add-passkey-support-for-login-qxdzoe

Conversation

@shuaiplus
Copy link
Copy Markdown
Owner

Motivation

  • Provide WebAuthn/passkey support so users can sign in or unlock the vault using a passkey as the primary (first-factor) authentication method without entering email/password when possible.
  • Allow users to register and manage up to 5 device passkeys per account (create, rename, delete) and support TOTP fallback when a user has 2FA enabled.
  • Enable the web frontend to receive vault unlock keys from a successful passkey login so the vault can be unlocked without re-entering the master password.

Description

  • Database and schema: add passkey_credentials and passkey_challenges tables to migrations/0001_init.sql and src/services/storage-schema.ts, and bump storage schema version in src/services/storage.ts.
  • Storage layer: implement passkey helpers in StorageService (createPasskeyChallenge, consumePasskeyChallenge, listPasskeysByUserId, getPasskeyByCredentialId, createPasskey, updatePasskeyName, deletePasskey, touchPasskeyUsage).
  • Backend handlers and routes: add src/handlers/passkeys.ts with endpoints for passkey registration/login flows and management, expose public endpoints (/identity/passkeys/*) in src/router-public.ts and authenticated management endpoints (/api/accounts/passkeys*) in src/router-authenticated.ts.
  • Types and utils: extend TokenResponse with VaultKeys and add PasskeyCredential type in src/types/index.ts, and add src/utils/passkey.ts to handle base64url/challenge parsing on server-side.
  • Frontend WebAuthn and API: add webapp/src/lib/passkey.ts client helpers, extend webapp/src/lib/api/auth.ts with loginWithPasskey, registerAccountPasskey, listAccountPasskeys, renameAccountPasskey, deleteAccountPasskey, and wire performPasskeyLogin in webapp/src/lib/app-auth.ts.
  • UI integration: add Passkey buttons to login and unlock views and TOTP fallback handling in webapp/src/components/AuthViews.tsx and webapp/src/App.tsx, add account Passkey management UI (create/rename/delete) in webapp/src/components/SettingsPage.tsx, and surface passkey data in AppMainRoutes and App props.
  • Behavior details: server validates clientDataJSON challenge/type/origin for create/get flows, enforces max 5 passkeys per user, and when a passkey contains wrapped vault keys they are returned as VaultKeys in the token response so the web app can unlock without the master password.

Testing

  • Built the web client successfully with npm run build (Vite build completed).
  • Type-checked the project with npx tsc --noEmit and it passed without errors.

Codex Task

Copilot AI review requested due to automatic review settings March 30, 2026 16:59
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
nodewarden 607c8c3 Mar 30 2026, 05:21 PM

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 964b12224f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

id: generateUUID(),
userId,
credentialId,
publicKey: 'client-asserted',
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist authenticator public key during passkey registration

The registration flow stores a hardcoded publicKey value ('client-asserted') instead of extracting and validating the credential public key from the attestation response. Because of this, newly created passkeys are not cryptographically bound to a real authenticator, and later login verification cannot be done correctly. This undermines the security guarantees of passkey enrollment and makes spoofed credential registration possible for any authenticated session.

Useful? React with 👍 / 👎.

Comment on lines +203 to +206
const parsedClientData = parseClientDataJSON(clientData);
const origin = new URL(request.url).origin;
if (!parsedClientData || parsedClientData.type !== 'webauthn.get' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) {
return identityErrorResponse('Passkey assertion invalid', 'invalid_grant', 400);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify WebAuthn assertion signature before issuing tokens

The login flow accepts a passkey assertion when clientDataJSON challenge/type/origin match, but it never verifies authenticatorData + signature against the stored credential key before minting access and refresh tokens. In this state, possession of a valid credentialId is enough to attempt forged assertions without proving control of the authenticator private key, which is a direct authentication bypass risk for passkey-based sign-in.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds passkey-first (WebAuthn) authentication and account-level passkey management so the web client can sign in/unlock and optionally receive vault unlock keys after a passkey login.

Changes:

  • Adds passkey credential/challenge persistence (new tables + storage helpers) and new passkey registration/login/management handlers + routes.
  • Extends token response/types to optionally return VaultKeys after passkey authentication.
  • Adds webapp WebAuthn helpers and UI wiring for passkey login/unlock and passkey CRUD in Settings.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
webapp/src/lib/types.ts Extends token success typing to include VaultKeys.
webapp/src/lib/passkey.ts Adds browser-side base64url + WebAuthn create/get helpers.
webapp/src/lib/app-auth.ts Adds performPasskeyLogin flow and maps VaultKeys into session.
webapp/src/lib/api/auth.ts Adds passkey API calls for login and account passkey CRUD.
webapp/src/components/SettingsPage.tsx Adds account passkey management UI (create/rename/delete).
webapp/src/components/AuthViews.tsx Adds passkey login/unlock buttons when supported.
webapp/src/components/AppMainRoutes.tsx Plumbs passkey props/handlers into Settings route.
webapp/src/App.tsx Wires passkey login, TOTP fallback handling, and passkey management actions/queries.
src/utils/passkey.ts Adds server-side base64url + challenge + clientDataJSON parsing helpers.
src/types/index.ts Extends TokenResponse and introduces PasskeyCredential type.
src/services/storage.ts Implements passkey storage/challenge helpers; bumps schema version.
src/services/storage-schema.ts Adds D1 schema statements for passkey tables + indexes.
src/router-public.ts Exposes public passkey login endpoints.
src/router-authenticated.ts Exposes authenticated passkey management endpoints.
src/handlers/passkeys.ts Implements passkey registration/login flows and CRUD endpoints.
migrations/0001_init.sql Adds passkey tables/indexes to initial migration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +409 to +417
async function handlePasskeyLogin() {
if (pendingAuthAction) return;
if (!passkeySupported()) {
pushToast('error', '当前浏览器不支持 Passkey');
return;
}
setPendingAuthAction('login');
try {
const result = await performPasskeyLogin(loginValues.email);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

handlePasskeyLogin() always uses loginValues.email when calling performPasskeyLogin(). In the locked/unlock flow the canonical email is profile?.email || session?.email (emailForLock), and loginValues.email may be empty/stale; this can break passkey unlock or trigger the empty-allowCredentials path.

Copilot uses AI. Check for mistakes.
Comment on lines +396 to +402
const login = pendingTotp
? await performTotpLogin(pendingTotp, totpCode, rememberDevice)
: (await (async () => {
const passkeyResult = await performPasskeyLogin(loginValues.email, totpCode);
if (passkeyResult.kind !== 'success') throw new Error(t('txt_totp_verify_failed'));
return passkeyResult.login;
})());
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

When passkey login requires TOTP, handleTotpVerify() re-runs performPasskeyLogin() which will prompt for a passkey assertion again. This is inefficient UX and forces a second WebAuthn ceremony; consider preserving the first assertion (or keeping the login challenge/pending state server-side) so the TOTP step can complete without repeating passkey auth.

Copilot uses AI. Check for mistakes.
{props.passkeySupported && (
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy}>
<Fingerprint size={16} className="btn-icon" />
Passkey 登录
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Passkey button labels are hard-coded ("Passkey 登录") instead of using the existing i18n mechanism (t('...')). Since the surrounding UI strings are localized via t(), these should be moved into the translation messages for consistency.

Suggested change
Passkey 登录
{t('txt_passkey_login')}

Copilot uses AI. Check for mistakes.
Comment on lines +209 to +224
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskey[]> {
const resp = await authedFetch('/api/accounts/passkeys');
if (!resp.ok) throw new Error('Failed to load passkeys');
const body = (await parseJson<{ data?: AccountPasskey[] }>(resp)) || {};
return Array.isArray(body.data) ? body.data : [];
}

export async function registerAccountPasskey(authedFetch: AuthedFetch, name: string, session: SessionState): Promise<void> {
const beginResp = await authedFetch('/api/accounts/passkeys/begin-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!beginResp.ok) throw new Error('Failed to start passkey registration');
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
if (!begin.challengeId || !begin.publicKey) throw new Error('Invalid registration challenge');
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Passkey-related API helpers throw hard-coded English error strings (e.g., "Failed to load passkeys" / "Failed to start passkey registration") while this module otherwise uses t('txt_...') for user-facing errors. For consistent localization, these messages should use t() keys and be added to the i18n catalog.

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +175
return jsonResponse({
challengeId,
publicKey: {
challenge,
rpId: rpIdFromUrl(request.url),
timeout: 60000,
userVerification: 'preferred',
allowCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })),
},
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

begin-login always includes allowCredentials, and when the user/email is unknown this becomes an empty array. Passing an empty allowCredentials to navigator.credentials.get() typically prevents discoverable credentials from being used; omit allowCredentials when you don't have a credential list (and consider rate-limiting this endpoint).

Suggested change
return jsonResponse({
challengeId,
publicKey: {
challenge,
rpId: rpIdFromUrl(request.url),
timeout: 60000,
userVerification: 'preferred',
allowCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })),
},
const allowCredentials =
passkeys.length > 0 ? passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })) : undefined;
const publicKey: {
challenge: string;
rpId: string;
timeout: number;
userVerification: 'preferred' | 'required' | 'discouraged';
allowCredentials?: Array<{ type: 'public-key'; id: string }>;
} = {
challenge,
rpId: rpIdFromUrl(request.url),
timeout: 60000,
userVerification: 'preferred',
};
if (allowCredentials) {
publicKey.allowCredentials = allowCredentials;
}
return jsonResponse({
challengeId,
publicKey,

Copilot uses AI. Check for mistakes.
Comment on lines +231 to +237
challengeId: begin.challengeId,
name,
wrappedVaultKeys: JSON.stringify({
symEncKey: session.symEncKey || '',
symMacKey: session.symMacKey || '',
}),
credential,
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

registerAccountPasskey uploads wrappedVaultKeys as JSON containing the session's decrypted symEncKey/symMacKey. Despite the name, these are not wrapped/encrypted here, which means the server will end up storing recoverable vault unlock keys; these should be encrypted/wrapped client-side (or via a proper WebAuthn extension) before being sent.

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +210
const parsedClientData = parseClientDataJSON(clientData);
const origin = new URL(request.url).origin;
if (!parsedClientData || parsedClientData.type !== 'webauthn.get' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) {
return identityErrorResponse('Passkey assertion invalid', 'invalid_grant', 400);
}

const credential = await storage.getPasskeyByCredentialId(credentialId);
if (!credential) return identityErrorResponse('Passkey not recognized', 'invalid_grant', 400);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Passkey login currently trusts clientDataJSON + credentialId without verifying authenticatorData/signature against the stored credential public key (and without checking the sign counter / rpIdHash / flags). As-is, anyone who can obtain a credentialId can forge a login by crafting clientDataJSON for the issued challenge.

Copilot uses AI. Check for mistakes.
Comment on lines +127 to +131
counter: 0,
transports: null,
name: name.slice(0, 100),
wrappedVaultKeys,
createdAt: now,
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

wrappedVaultKeys is persisted verbatim and then returned to the client as VaultKeys. In the current flow the webapp uploads decrypted symEncKey/symMacKey JSON, so this stores vault unlock keys server-side in recoverable form; these should be encrypted/wrapped (or avoided entirely) before storage to prevent DB compromise from yielding vault keys.

Copilot uses AI. Check for mistakes.
Comment on lines +278 to +282
if (path === '/identity/passkeys/begin-login' && method === 'POST') {
return handleBeginPasskeyLogin(request, env);
}

if (path === '/identity/passkeys/finish-login' && method === 'POST') {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The new passkey endpoints are exposed publicly without the rate limiting applied to other sensitive public routes (e.g., prelogin, password-hint, revocation). Consider adding enforcePublicRateLimit here to reduce credential enumeration/abuse risk, especially for begin-login.

Suggested change
if (path === '/identity/passkeys/begin-login' && method === 'POST') {
return handleBeginPasskeyLogin(request, env);
}
if (path === '/identity/passkeys/finish-login' && method === 'POST') {
if (path === '/identity/passkeys/begin-login' && method === 'POST') {
const blocked = await enforcePublicRateLimit(
'public-sensitive',
LIMITS.rateLimit.sensitivePublicRequestsPerMinute,
);
if (blocked) return blocked;
return handleBeginPasskeyLogin(request, env);
}
if (path === '/identity/passkeys/finish-login' && method === 'POST') {
const blocked = await enforcePublicRateLimit(
'public-sensitive',
LIMITS.rateLimit.sensitivePublicRequestsPerMinute,
);
if (blocked) return blocked;

Copilot uses AI. Check for mistakes.
@shuaiplus shuaiplus merged commit edd2ba2 into main Mar 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants