Skip to content

feat: implement passkey-first authentication with WebAuthn - #681

Merged
2witstudios merged 9 commits into
masterfrom
feat/passkey-auth-v2
Feb 13, 2026
Merged

2witstudios merged 9 commits into
masterfrom
feat/passkey-auth-v2

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Implements passwordless authentication via WebAuthn passkeys (TouchID, FaceID, Windows Hello, security keys) with passkey-first signup.

This PR is a clean rebase of #680 on updated master to resolve migration conflicts.

Passkey-First Signup (New!)

  • Passkey as primary signup method - users create accounts with name, email, and passkey (no password required)
  • POST /api/auth/signup-passkey/options - Generate registration challenge for new users
  • POST /api/auth/signup-passkey - Verify passkey and atomically create user + passkey
  • PasskeySignupButton component with WebAuthn support detection
  • Redesigned signup page: passkey primary CTA, OAuth secondary, password collapsed
  • Magic link fallback for browsers without WebAuthn support

WebAuthn Configuration (Updated)

  • residentKey: 'required' - ensures discoverable credentials for true passwordless
  • userVerification: 'required' - ensures biometric/PIN is always used
  • Per FIDO Alliance 2026 design guidelines

Infrastructure

  • Add passkeys table schema with migration
  • Add passkey-service with Result pattern matching magic-link-service
  • Add rate limiting for passkey registration and authentication
  • Add metadata column to verification_tokens for signup context

Registration Flow (Adding to existing account)

  • POST /api/auth/passkey/register/options - Generate registration challenge
  • POST /api/auth/passkey/register - Verify and store passkey
  • PasskeyManager component for settings page

Authentication Flow

  • POST /api/auth/passkey/authenticate/options - Generate auth challenge
  • POST /api/auth/passkey/authenticate - Verify passkey and create session
  • PasskeyLoginButton component for sign-in page

Security

  • Challenge stored as hash only (5-minute expiry, single-use)
  • Counter replay protection with atomic database update
  • Session fixation prevention (revoke existing sessions on login)
  • CSRF protection on all endpoints (login CSRF for unauthenticated, session CSRF for authenticated)
  • Email verified automatically on passkey signup (device ownership proven)

Test Plan

Passkey Signup (New)

  • Create account with passkey on signup page
  • Verify redirects to dashboard after successful signup
  • Verify email shows as verified (no verification email needed)
  • Verify fallback to magic link works when WebAuthn unavailable
  • Verify "email exists" error redirects to signin

Passkey Login

  • Register passkey via TouchID/FaceID on Mac
  • Register security key (if available)
  • Login with registered passkey
  • Multiple passkeys per user
  • Delete passkey, can no longer use it
  • Rename passkey
  • Rate limiting triggers on repeated failures

Database Migration

Run pnpm db:migrate to apply:

  • 0085_marvelous_norrin_radd.sql - Adds metadata column to verification_tokens

Related Issues

Implements #568 (Infrastructure), #569 (Registration), #570 (Authentication)
Part of Passkeys Epic #586
Advances "Work OS: Passwordless + Enterprise" GitHub project

Supersedes #680 (rebase to resolve migration conflicts)


"Passwords are insecure and obsolete. All new apps should be using passkey auth." - Eric Elliott

Summary by CodeRabbit

  • New Features

    • Passwordless Passkey sign-in and signup: added Passkey buttons, conditional autofill, and browser-support fallbacks.
    • Account Settings: Passkeys manager to register, name/rename, and delete passkeys; shows device type, created/last-used dates, and sync status.
    • Sign-up and sign-in flows now support WebAuthn with user feedback and post-auth redirects.
  • Tests

    • Comprehensive test coverage added for passkey service.

Add passwordless authentication via passkeys (TouchID, FaceID, Windows Hello, security keys):

**Infrastructure:**
- Add passkeys table schema with migration
- Add passkey-service with Result pattern matching magic-link-service
- Add rate limiting for passkey registration and authentication

**Registration Flow:**
- POST /api/auth/passkey/register/options - Generate registration challenge
- POST /api/auth/passkey/register - Verify and store passkey
- PasskeyManager component for settings page

**Authentication Flow:**
- POST /api/auth/passkey/authenticate/options - Generate auth challenge
- POST /api/auth/passkey/authenticate - Verify passkey and create session
- PasskeyLoginButton component for sign-in page

**Security:**
- Challenge stored as hash only (5-minute expiry, single-use)
- Counter replay protection with atomic database update
- Session fixation prevention (revoke existing sessions)
- CSRF protection on all endpoints

Implements #568, #569, #570 (Passkeys Epic #586)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Feb 13, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds full WebAuthn/passkey support: DB migration and schema, passkey service and tests, server API routes for register/auth/list/rename/delete, frontend components/hooks for signup/login/management, rate-limits, and monitoring event extensions.

Changes

Cohort / File(s) Summary
Dependencies
apps/web/package.json, packages/lib/package.json
Add @simplewebauthn/browser and @simplewebauthn/server ^13.2.2.
DB migration & schema
packages/db/drizzle/0084_loud_blue_blade.sql, packages/db/drizzle/0085_marvelous_norrin_radd.sql, packages/db/drizzle/meta/_journal.json, packages/db/src/schema/auth.ts, packages/db/src/index.ts
Add passkeys table (FK to users), indexes, journal entries; add verification_tokens.metadata column; rename email unsubscribe token fields to snake_case and export passkeys in schema index.
Passkey service & tests
packages/lib/src/auth/passkey-service.ts, packages/lib/src/auth/passkey-service.test.ts, packages/lib/src/auth/index.ts
New passkey service implementing generate/verify registration & authentication, challenge lifecycle, counter/replay protection, list/delete/rename, signup support; comprehensive unit tests; re-export added.
Rate limits & monitoring
packages/lib/src/security/distributed-rate-limit.ts, packages/lib/src/monitoring/activity-tracker.ts
Add DISTRIBUTED_RATE_LIMITS entries PASSKEY_REGISTER and PASSKEY_AUTH; extend trackAuthEvent union with passkey_login, passkey_registered, passkey_deleted.
API routes — register & authenticate
apps/web/src/app/api/auth/passkey/register/options/route.ts, .../register/route.ts, .../authenticate/options/route.ts, .../authenticate/route.ts, .../signup-passkey/options/route.ts, .../signup-passkey/route.ts
New POST endpoints to generate and verify registration/authentication/signup options; include CSRF/login-CSRF checks, per-IP and per-user distributed rate-limits, verification via server lib, session creation for auth/signup, mapped error handling and logging.
API routes — management
apps/web/src/app/api/auth/passkey/route.ts, apps/web/src/app/api/auth/passkey/[passkeyId]/route.ts
GET list passkeys, DELETE passkey, PATCH rename passkey; require session auth, CSRF (skippable for Bearer), return structured errors and events.
Frontend — signup & login components
apps/web/src/components/auth/PasskeySignupButton.tsx, apps/web/src/components/auth/PasskeyLoginButton.tsx, apps/web/src/components/auth/index.ts
Add PasskeySignupButton and PasskeyLoginButton components and associated hooks (useWebAuthnSupport, useConditionalPasskeyUI); re-export from auth index.
Frontend — hooks & CSRF
apps/web/src/hooks/useCSRFToken.ts, apps/web/src/hooks/useLoginCSRF.ts
Add client hooks to fetch CSRF and login-CSRF tokens with refresh helpers.
Frontend — signin & signup pages
apps/web/src/app/auth/signin/page.tsx, apps/web/src/app/auth/signup/page.tsx
Wire passkey UI into signin and signup flows; add AuthMethod type update and conditional UI behaviors.
Frontend — settings manager
apps/web/src/components/settings/PasskeyManager.tsx, apps/web/src/app/settings/account/page.tsx
Add PasskeyManager to list/register/rename/delete passkeys in Account Settings; integrate UI and icons.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Browser as Browser (WebAuthn)
    participant Server
    participant DB

    Client->>Server: POST /api/auth/passkey/register/options
    Server->>Server: Validate session & CSRF, enforce rate limit, check per-user limit
    Server->>DB: Store challenge (verification token)
    Server-->>Client: Return registration options + challengeId
    Client->>Browser: navigator.credentials.create()
    Browser-->>Client: attestationObject + clientDataJSON
    Client->>Server: POST /api/auth/passkey/register
    Server->>Server: Verify registration via simplewebauthn
    Server->>DB: Insert passkey record
    Server-->>Client: Return { passkeyId }
Loading
sequenceDiagram
    participant Client
    participant Browser as Browser (WebAuthn)
    participant Server
    participant DB

    Client->>Server: POST /api/auth/passkey/authenticate/options
    Server->>Server: Validate CSRF, rate limit by IP, generate options
    Server->>DB: Store challenge (may use system user)
    Server-->>Client: Return options + challengeId
    Client->>Browser: navigator.credentials.get()
    Browser-->>Client: authenticatorData + signature
    Client->>Server: POST /api/auth/passkey/authenticate
    Server->>DB: Retrieve passkey by credentialId
    Server->>Server: Verify authentication, check counter/replay
    Server->>DB: Revoke old sessions, create new session
    Server->>Server: Generate CSRF token for new session
    Server-->>Client: Set cookies, return success + redirect
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐇 I hopped into the code to play,

Tiny keys that keep intruders away,
No more passwords to fumble or fret,
A hop, a click, and your session is set,
Cheers — a rabbit-approved secure day!

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (21 files):

⚔️ apps/web/package.json (content)
⚔️ apps/web/src/app/auth/signin/page.tsx (content)
⚔️ apps/web/src/app/auth/signup/page.tsx (content)
⚔️ apps/web/src/app/settings/account/page.tsx (content)
⚔️ apps/web/src/components/auth/index.ts (content)
⚔️ apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx (content)
⚔️ apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx (content)
⚔️ apps/web/src/lib/ai/core/__tests__/provider-factory.test.ts (content)
⚔️ apps/web/src/lib/ai/core/ai-providers-config.ts (content)
⚔️ apps/web/src/lib/ai/core/provider-factory.ts (content)
⚔️ apps/web/src/lib/ai/shared/error-messages.ts (content)
⚔️ apps/web/src/lib/repositories/index.ts (content)
⚔️ packages/db/drizzle/meta/_journal.json (content)
⚔️ packages/db/src/index.ts (content)
⚔️ packages/db/src/schema/ai.ts (content)
⚔️ packages/db/src/schema/auth.ts (content)
⚔️ packages/lib/package.json (content)
⚔️ packages/lib/src/auth/index.ts (content)
⚔️ packages/lib/src/monitoring/activity-tracker.ts (content)
⚔️ packages/lib/src/security/distributed-rate-limit.ts (content)
⚔️ pnpm-lock.yaml (content)

These conflicts must be resolved before merging into master.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: implementing passkey-first authentication with WebAuthn. It is concise, specific, and directly reflects the substantial infrastructure, endpoints, components, and security mechanisms added across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/passkey-auth-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/schema/auth.ts (1)

171-181: ⚠️ Potential issue | 🟡 Minor

Add missing passkeys relation to usersRelations for schema consistency.

The passkeysRelations defines the inverse relation (passkey → user), but usersRelations lacks passkeys: many(passkeys). While current code queries passkeys directly via db.query.passkeys, the relation should be included to maintain consistency with all other user-related tables and enable relational queries like db.query.users.findFirst({ with: { passkeys: true } }).

Proposed fix
export const usersRelations = relations(users, ({ many }) => ({
  deviceTokens: many(deviceTokens),
  chatMessages: many(chatMessages),
  aiSettings: many(userAiSettings),
  mcpTokens: many(mcpTokens),
  verificationTokens: many(verificationTokens),
  socketTokens: many(socketTokens),
  subscriptions: many(subscriptions),
  sessions: many(sessions),
  emailUnsubscribeTokens: many(emailUnsubscribeTokens),
+  passkeys: many(passkeys),
}));
🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/auth/passkey/authenticate/route.ts`:
- Around line 109-116: The current code calls
sessionService.revokeAllUserSessions(userId, 'passkey_login') which
force-terminates every active session on passkey login; change this to a less
disruptive behavior by either revoking only the current session (if one exists)
or rotating the current session token instead of calling revokeAllUserSessions,
and make the behavior configurable (e.g., add a flag or ENV to keep aggressive
revoke as an option). Update the code paths around revokeAllUserSessions,
sessionService, and the passkey login flow to call a new method like
sessionService.revokeCurrentSession or sessionService.rotateSessionToken (or
gate the full revoke behind a config check), and if you keep full-revoke as an
option, add a loggers.auth.info message and ensure the API surface returns a
user-facing notice indicating other sessions will be terminated.

In `@apps/web/src/components/auth/PasskeyLoginButton.tsx`:
- Around line 100-107: The cookie parsing is fragile because split('=')[1]
truncates values containing '='; update both places (in handleLogin and
startConditionalUI where csrfCookie/newCsrfToken are used) to extract the value
by finding the first '=' and taking the substring after it (e.g., use
indexOf('=') + 1 and slice/substring) so the entire cookie value (including any
'=' chars) is preserved before storing into localStorage as 'csrfToken'.

In `@apps/web/src/components/settings/PasskeyManager.tsx`:
- Around line 39-43: The SWR fetcher in PasskeyManager.tsx and the component's
mutation calls currently use bare fetch which bypasses the app's auth layer;
import and use fetchWithAuth from '@/lib/auth/auth-fetch' instead of fetch in
the fetcher (const fetcher) and in all POST/DELETE/PATCH mutation requests in
this file so bearer-token platforms, automatic 401 retry, and CSRF refresh are
respected; update call sites to pass the same url and options to fetchWithAuth
and remove or adapt manual CSRF header logic if fetchWithAuth already manages
CSRF/token headers.

In `@packages/lib/src/auth/passkey-service.test.ts`:
- Around line 90-98: The afterEach cleanup only deletes verificationTokens for
testUserId but generateAuthenticationOptions(...) creates tokens with userId
'system', so update the afterEach in the test to also remove those system
tokens: extend the db.delete call that targets verificationTokens (the
verificationTokens table used alongside db.delete and eq in afterEach) to remove
rows for both testUserId and 'system' (e.g., add an OR/in condition), ensuring
all challenges created by generateAuthenticationOptions are cleaned up between
tests.

In `@packages/lib/src/auth/passkey-service.ts`:
- Around line 483-508: The atomic counter guard is missing: update the
db.update(passkeys).where(...) call used with passkey.id so it includes a
counter comparison (e.g., passkeys.counter <
verification.authenticationInfo.newCounter) using Drizzle's sql raw expression,
then check counterUpdateResult (the returned rows) and treat an empty result as
COUNTER_REPLAY_DETECTED instead of comparing against the stale passkey.counter;
ensure the update still sets counter and lastUsedAt and return ok only when the
update returned at least one row.
- Around line 357-381: The else-branch inserts userId: 'system' into
verificationTokens which violates the verificationTokens.userId FK constraint;
update the schema and code so anonymous challenges don't reference a bogus user:
modify the verificationTokens table to allow userId to be nullable (remove
.notNull() / FK or make FK DEFERRABLE) and change the insert in
passkey-service.ts (the db.insert(verificationTokens).values(...) call in the
branch that checks challengeUserId) to set userId to null or omit the field when
challengeUserId is falsy; alternatively, if you prefer not to change the
existing table, create a new webauthn_challenges table (or use Redis) and move
the anonymous-challenge inserts/reads there and update the verification/lookup
paths accordingly.
🧹 Nitpick comments (11)
apps/web/src/hooks/useLoginCSRF.ts (1)

1-51: Consider extracting a shared CSRF hook to reduce duplication with useCSRFToken.

useLoginCSRF and useCSRFToken are nearly identical — they differ only in the endpoint URL (/api/auth/login-csrf vs /api/auth/csrf). A shared useCSRFBase(endpoint) helper would eliminate the copy-paste and make both easier to maintain.

Also, refreshToken (Line 41-43) is a trivial passthrough of fetchToken — you can return fetchToken directly as refreshToken and drop the extra wrapper.

packages/db/src/schema/auth.ts (1)

223-239: Naming convention inconsistency: snake_case DB column names vs. camelCase in older tables.

The passkeys table uses snake_case for DB column names ('user_id', 'credential_id', etc.), which matches emailUnsubscribeTokens but diverges from older tables in this file (deviceTokens, verificationTokens, etc.) that use camelCase ('userId', 'tokenHash'). This isn't a bug — Drizzle maps either way — but the inconsistency within the same schema file may cause confusion.

Not blocking, just flagging for awareness since both conventions coexist.

apps/web/src/app/api/auth/passkey/[passkeyId]/route.ts (1)

13-15: Route-level updateNameSchema duplicates service-level validation.

The passkey service already validates name with the same constraints (min(1).max(255)). The duplication is minor and adds defense-in-depth at the API boundary, but the hardcoded 255 here could drift from PASSKEY_CONFIG.maxNameLength. Consider importing the constant.

packages/lib/src/auth/passkey-service.ts (1)

40-54: z.any() violates the "never use any types" coding guideline.

Both verifyRegSchema.response (Line 42) and verifyAuthSchema.response (Line 52) use z.any(). Even though SimpleWebAuthn validates the response internally, using z.any() bypasses TypeScript safety entirely. Consider using z.record(z.unknown()) or a more specific schema matching RegistrationResponseJSON / AuthenticationResponseJSON shapes.

As per coding guidelines: "Never use any types - always use proper TypeScript types".

apps/web/src/components/auth/PasskeyLoginButton.tsx (1)

40-131: Duplicate authentication flow between handleLogin and startConditionalUI.

Both functions share the same fetch-options → start-authentication → verify → extract-CSRF → redirect pipeline. Consider extracting the shared core (verify + post-login) into a helper to reduce duplication and ensure consistent behavior for cookie handling and error messages.

Also applies to: 187-261

apps/web/src/app/auth/signin/page.tsx (1)

22-22: 'passkey' variant in AuthMethod is unused.

The 'passkey' value is never assigned via setAuthMethod. Only 'magic-link' and 'password' are used. If passkey login doesn't need its own view mode (it's a button overlay on existing modes), this variant is dead code.

apps/web/src/app/settings/account/page.tsx (1)

24-24: Duplicate lucide-react import source.

Smartphone and Fingerprint are imported from lucide-react on line 24, while line 14 already imports many icons from the same package. Consolidating them into a single import statement would be cleaner.

Proposed fix
-import { User, Mail, Calendar, AlertTriangle, Loader2, ArrowLeft, Upload, X, CheckCircle2, AlertCircle, Download, Clock } from "lucide-react";
+import { User, Mail, Calendar, AlertTriangle, Loader2, ArrowLeft, Upload, X, CheckCircle2, AlertCircle, Download, Clock, Smartphone, Fingerprint } from "lucide-react";
...
-import { Smartphone, Fingerprint } from "lucide-react";
apps/web/src/app/api/auth/passkey/register/route.ts (2)

17-21: z.any() for response field violates the "no any types" guideline.

The coding guidelines state: "Never use any types." While the WebAuthn response is complex and validated downstream by @simplewebauthn/server, using z.any() bypasses all schema-level validation, meaning malformed payloads reach the service layer before being rejected.

Consider using z.record(z.string(), z.unknown()) or z.object({}).passthrough() as a lightweight structural guard that still satisfies the guideline. As per coding guidelines, "Never use any types - always use proper TypeScript types."

Proposed fix
 const verifySchema = z.object({
-  response: z.any(), // WebAuthn response - validated by simplewebauthn
+  response: z.record(z.string(), z.unknown()), // Structural guard; full validation by simplewebauthn
   expectedChallenge: z.string().min(1),
   name: z.string().max(255).optional(),
 });

77-86: Zod error format inconsistency with codebase pattern.

Line 83 uses validation.error.issues, but the codebase convention (per learnings) is to use validation.error.flatten().fieldErrors for consistency across API routes. Note that in Zod v4, the equivalent would be z.flattenError(validation.error) when that migration happens, but for now the existing pattern should be maintained. Based on learnings, "Maintain the existing pattern of using validation.error.flatten().fieldErrors across API routes."

Proposed fix
     if (!validation.success) {
       return NextResponse.json(
-        { error: 'Invalid request body', details: validation.error.issues },
+        { error: 'Invalid request body', details: validation.error.flatten().fieldErrors },
         { status: 400 }
       );
     }
apps/web/src/app/api/auth/passkey/authenticate/route.ts (2)

19-23: Same z.any() issue as the register route — replace with a structural guard.

Same guideline violation as in the register route. As per coding guidelines, "Never use any types - always use proper TypeScript types."

Proposed fix
 const verifySchema = z.object({
-  response: z.any(), // WebAuthn response - validated by simplewebauthn
+  response: z.record(z.string(), z.unknown()), // Structural guard; full validation by simplewebauthn
   expectedChallenge: z.string().min(1),
   csrfToken: z.string().min(1),
 });

57-62: Zod error format — use .flatten().fieldErrors for consistency.

Same as the register route: align with the codebase convention. Based on learnings, "Maintain the existing pattern of using validation.error.flatten().fieldErrors across API routes."

Proposed fix
     if (!validation.success) {
       return NextResponse.json(
-        { error: 'Invalid request body', details: validation.error.issues },
+        { error: 'Invalid request body', details: validation.error.flatten().fieldErrors },
         { status: 400 }
       );
     }

Comment thread apps/web/src/app/api/auth/passkey/authenticate/route.ts
Comment thread apps/web/src/components/auth/PasskeyLoginButton.tsx Outdated
Comment thread apps/web/src/components/settings/PasskeyManager.tsx
Comment thread packages/lib/src/auth/passkey-service.test.ts
Comment thread packages/lib/src/auth/passkey-service.ts
Comment thread packages/lib/src/auth/passkey-service.ts
2witstudios and others added 2 commits February 13, 2026 12:18
- Fix mock to return excludeCredentials when passkeys exist
- Create system user for anonymous auth challenges (conditional UI)
- Update migration to create system user on deploy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add missing passkeys relation to usersRelations for schema consistency
- Fix cookie parsing to handle '=' characters in CSRF token values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Addressing CodeRabbit Review Feedback

Thank you for the thorough review! I've addressed the following items in commit 6623adf:

✅ Fixed

  1. Missing passkeys relation - Added passkeys: many(passkeys) to usersRelations for schema consistency
  2. Cookie parsing issue - Fixed split('=')[1] to use substring(indexOf('=') + 1) to preserve = chars in CSRF token values

Already Addressed (in earlier commits)

  1. FK constraint with 'system' user - Changed to use system-passkey-auth user ID which is created by migration

Acknowledged (not changing)

  1. z.any() for WebAuthn responses - While CodeRabbit flagged this as a guideline violation, the WebAuthn response structures are complex and validated by @simplewebauthn/server. Using z.any() here is intentional to avoid duplicating validation logic. The structural validation happens downstream.

  2. Duplicate CSRF hooks - The useLoginCSRF and useCSRFToken serve different purposes (unauthenticated vs authenticated flows) and use different endpoints. The slight duplication is acceptable for clarity.

  3. Error format consistency - The validation.error.issues pattern is consistent with other WebAuthn-related routes in this PR. Will align in a follow-up if needed.


Note on Failing Test

The failing test DELETE_afterMultipleRoleChanges_deniesAccessWithStaleToken is unrelated to this PR - it's in the admin gift subscription endpoint. This appears to be a pre-existing flaky test.

- Implement atomic counter replay protection with SQL WHERE clause
- Use fetchWithAuth in PasskeyManager for consistent auth handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Additional Critical Fixes (commit 6325ec7)

✅ Counter Replay Protection (Critical)

Implemented truly atomic counter replay protection using SQL WHERE clause:

.where(
  and(
    eq(passkeys.id, passkey.id),
    sql`${passkeys.counter} < ${newCounter}`
  )
)

If no rows are updated (counter wasn't greater), we return COUNTER_REPLAY_DETECTED error.

✅ Use fetchWithAuth (Major)

Updated PasskeyManager to use fetchWithAuth instead of bare fetch for consistent auth handling across the app. This ensures:

  • Bearer token handling on non-web platforms
  • Automatic CSRF token management
  • 401 retry logic

All CI checks passed on previous commit. These changes should also pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/web/src/components/settings/PasskeyManager.tsx`:
- Around line 285-301: The icon-only Button elements for renaming and deleting
passkeys are missing accessible labels; update the two Button components (the
one that calls setEditPasskey/setNewName and the one that calls
setDeletePasskeyId) to include descriptive accessibility attributes such as
aria-label or aria-labelledby (e.g., aria-label="Rename passkey" and
aria-label="Delete passkey") and ensure the labels reference the specific
passkey (use passkey.name or passkey.id in the label text) so screen readers can
identify the action and which passkey it targets while keeping the Pencil and
Trash2 icons visually unchanged.
- Around line 63-66: The useSWR call in PasskeyManager currently omits SWR
config; update the useSWR invocation that fetches '/api/auth/passkey' (the const
{ data, error, isLoading } = useSWR<{ passkeys: Passkey[] }>(...)) to pass a
config object with revalidateOnFocus: false so the settings component does not
refetch when the user switches tabs.

In `@packages/lib/src/auth/passkey-service.ts`:
- Around line 348-384: generateAuthenticationOptions currently inserts a new
'webauthn_auth' verificationTokens row (using challengeId, challengeHash,
PASSKEY_CONFIG.systemUserId or challengeUserId) but never cleans up old/stale
auth challenges, which will accumulate (especially for the system user). Before
the db.insert(...) in generateAuthenticationOptions (the block that uses
simpleGenerateAuthenticationOptions and writes to verificationTokens), delete
previous rows of type 'webauthn_auth' for the same user context: remove expired
rows and/or limit to non-expired tokens for either challengeUserId or
PASSKEY_CONFIG.systemUserId (use verificationTokens table, filtering by userId
and type === 'webauthn_auth'), mirroring the cleanup logic used in
generateRegistrationOptions so only current/valid challenges remain. Ensure the
cleanup runs regardless of whether challengeUserId is set and uses the same
expiresAt/expiry semantics as the registration cleanup.
- Around line 486-507: The current atomic update rejects authenticators that
report a zero signCount; change the conditional used in the db.update(where ...)
so authenticators with a stored counter of 0 (or newCounter of 0) are
allowed—e.g., replace the sql`${passkeys.counter} < ${newCounter}` check with a
predicate that permits updates when passkeys.counter = 0 OR passkeys.counter <
newCounter (or alternatively read the existing passkey counter and branch to
skip the strict check when stored counter === 0); update the where clause used
in the update call around passkeys, newCounter, and the sql template to
implement this logic so genuine zero-count passkeys are accepted instead of
returning COUNTER_REPLAY_DETECTED.
🧹 Nitpick comments (5)
packages/db/src/schema/auth.ts (2)

225-240: DB column naming convention is inconsistent with existing tables.

The passkeys table (and emailUnsubscribeTokens) uses snake_case for database column names ('user_id', 'credential_id', 'public_key', etc.), while all pre-existing tables in this file use camelCase ('userId', 'tokenHash', 'expiresAt', etc.). This creates a mixed convention within the same schema file, which can cause confusion when writing raw SQL or inspecting the database directly.

Consider aligning to one convention. If snake_case is the desired direction going forward, a follow-up migration to rename legacy columns would keep things consistent.


228-228: Redundant index on credentialId.

Line 228 declares .unique() on credentialId, which in PostgreSQL automatically creates a unique index. The explicit index('passkeys_credential_id_idx') on line 239 is therefore redundant and adds unnecessary write overhead.

Proposed fix
 }, (table) => ({
   userIdx: index('passkeys_user_id_idx').on(table.userId),
-  credentialIdx: index('passkeys_credential_id_idx').on(table.credentialId),
 }));

Also applies to: 239-239

apps/web/src/components/auth/PasskeyLoginButton.tsx (1)

189-265: Extract shared authentication-verify-redirect logic to reduce duplication.

startConditionalUI and handleLogin share nearly identical flows: fetch options → startAuthentication → POST verify → parse CSRF cookie → redirect. The cookie-parsing block (Lines 239-247) and the verify+redirect block are verbatim copies. A shared helper (e.g., verifyAndRedirect(verifyRes, onSuccess)) would eliminate ~25 duplicated lines and ensure future fixes (like adding decodeURIComponent to the cookie value) are applied in one place.

♻️ Sketch of extracted helper
+async function handleVerifyAndRedirect(
+  verifyRes: Response,
+  onSuccess?: (redirectUrl: string) => void
+): Promise<boolean> {
+  if (!verifyRes.ok) {
+    const error = await verifyRes.json();
+    toast.error(error.error || 'Authentication failed');
+    return false;
+  }
+
+  const { redirectUrl } = await verifyRes.json();
+
+  const csrfCookie = document.cookie
+    .split('; ')
+    .find(row => row.startsWith('csrf_token='));
+
+  if (csrfCookie) {
+    const eqIndex = csrfCookie.indexOf('=');
+    const newCsrfToken = csrfCookie.substring(eqIndex + 1);
+    localStorage.setItem('csrfToken', newCsrfToken);
+  }
+
+  toast.success('Signed in successfully');
+
+  if (onSuccess) {
+    onSuccess(redirectUrl);
+  } else {
+    window.location.href = redirectUrl;
+  }
+  return true;
+}

Then handleLogin can add its richer error handling before delegating:

// In handleLogin, after the verifyRes fetch:
if (!verifyRes.ok) {
  // specific error code handling...
  return;
}
await handleVerifyAndRedirect(verifyRes, onSuccess);
apps/web/src/components/settings/PasskeyManager.tsx (1)

39-40: Nit: Move this import to the top with the other imports.

The fetchWithAuth import is separated from the rest of the imports by the Passkey interface. Group all imports together at the top of the file for consistency.

packages/lib/src/auth/passkey-service.ts (1)

43-57: z.any() violates the project's "never use any types" guideline.

Lines 45 and 55 use z.any() for WebAuthn response fields. I see from the PR discussion that this is intentional since @simplewebauthn/server performs its own structural validation, and duplicating that complex schema in Zod adds maintenance burden. That rationale is reasonable, but it's still a deviation from the coding guidelines.

A lighter-weight compromise: use z.record(z.string(), z.unknown()) or z.object({}).passthrough() to at least enforce that the input is a non-null object, preventing obviously wrong types (strings, numbers, arrays) from reaching the library.

Suggested minimal constraint
 const verifyRegSchema = z.object({
   userId: z.string().min(1),
-  response: z.any(), // RegistrationResponseJSON validated by simplewebauthn
+  response: z.record(z.string(), z.unknown()), // structural validation delegated to simplewebauthn
   expectedChallenge: z.string().min(1),
   name: z.string().max(PASSKEY_CONFIG.maxNameLength).optional(),
 });
 const verifyAuthSchema = z.object({
-  response: z.any(), // AuthenticationResponseJSON validated by simplewebauthn
+  response: z.record(z.string(), z.unknown()), // structural validation delegated to simplewebauthn
   expectedChallenge: z.string().min(1),
 });

As per coding guidelines: "**/*.{ts,tsx}: Never use any types - always use proper TypeScript types".

Comment thread apps/web/src/components/settings/PasskeyManager.tsx
Comment thread apps/web/src/components/settings/PasskeyManager.tsx
Comment thread packages/lib/src/auth/passkey-service.ts
Comment thread packages/lib/src/auth/passkey-service.ts
- Add revalidateOnFocus: false to SWR config in PasskeyManager
- Add aria-label attributes to icon-only buttons for accessibility
- Support authenticators with counter=0 (iCloud Keychain, Google Password Manager)
- Add cleanup of stale webauthn_auth challenges before creating new ones

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Response to CodeRabbit Review

Addressed in commit 106ec41:

  1. SWR revalidateOnFocus (PasskeyManager.tsx:66) - Added revalidateOnFocus: false to the SWR config.

  2. Icon accessibility (PasskeyManager.tsx:301) - Added aria-label="Rename passkey" and aria-label="Delete passkey" to the icon-only buttons.

  3. Counter=0 authenticators (passkey-service.ts:507) - Updated the SQL condition to allow authenticators with counter=0 (common for iCloud Keychain, Google Password Manager synced passkeys). The new condition is: passkeys.counter = 0 OR passkeys.counter < newCounter

  4. Stale webauthn_auth cleanup (passkey-service.ts:384) - Added cleanup of old unused webauthn_auth challenges before creating new ones, matching the existing cleanup pattern in generateRegistrationOptions.

Regarding session revocation (authenticate/route.ts:116):

The aggressive session revocation on passkey login is intentional per the PR objectives ("Session fixation prevention"). This is a security-first design choice:

  • Passkey authentication is passwordless and highly secure - if someone is logging in with a passkey, we want to ensure no stale sessions from potentially compromised credentials remain active
  • This is a stronger security posture appropriate for passkey-based authentication
  • The behavior is logged so users see "N sessions revoked" feedback

If the team prefers a less aggressive policy (e.g., only rotate the current session token), I can make this configurable via an environment variable. However, the current default provides the strongest session fixation protection.

Implements passkey as the primary signup method per FIDO Alliance 2026 guidelines:

- Update WebAuthn config: residentKey='required', userVerification='required'
- Add generateRegistrationOptionsForSignup() for new user registration
- Add verifySignupRegistration() for atomic user + passkey creation
- Create /api/auth/signup-passkey/* routes for signup flow
- Add PasskeySignupButton component with WebAuthn support detection
- Redesign signup page: passkey primary, OAuth secondary, password collapsed
- Add metadata column to verification_tokens for signup context
- Add comprehensive tests for new signup functions

Users can now create accounts with just name, email, and a passkey.
Password signup remains available but is de-emphasized.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios 2witstudios changed the title feat: implement WebAuthn passkey authentication feat: implement passkey-first authentication with WebAuthn Feb 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/auth/signup-passkey/options/route.ts`:
- Around line 92-113: Add a small error map and use it before falling back to
the 500 path: detect result.error.code (e.g. 'VALIDATION_FAILED') alongside
'EMAIL_EXISTS' in the options route handler so you return an appropriate
status/message instead of always returning 500; update the conditional around
result.ok in apps/web/src/app/api/auth/signup-passkey/options/route.ts to
consult the new errorMap (similar to the sibling route) and call
NextResponse.json with the mapped status/message for recognized service error
codes, otherwise keep the existing logging via loggers.auth.warn and the 500
response.

In `@apps/web/src/app/api/auth/signup-passkey/route.ts`:
- Around line 152-158: The insert of userAiSettings
(db.insert(userAiSettings).values({...})) can throw and currently is
unprotected, causing a 500 after the user and passkey are already created; wrap
this insert in its own try-catch inside the signup-passkey route handler (near
the existing provisionGettingStartedDriveIfNeeded try-catch), catch and log the
error (including the error object) and do not rethrow so the response still
succeeds; optionally record a telemetry event or fallback to a no-op if the
insert fails so account creation remains atomic from the client's perspective.
- Around line 170-177: The trackAuthEvent call in signup-passkey route currently
sends full PII (email, name) into userActivities.metadata; before invoking
trackAuthEvent (in apps/web/src/app/api/auth/signup-passkey/route.ts) mask
sensitive fields (e.g., obfuscate email local-part leaving domain like
a****@example.com and redact or initials-only for name) and pass the masked
values instead of raw email/name, or alternatively remove those fields entirely;
reference the function trackAuthEvent and the userActivities metadata field when
making this change so PII is never persisted without a documented retention
policy.

In `@packages/lib/src/auth/passkey-service.test.ts`:
- Around line 294-299: The assertions use !== null but Drizzle's findFirst
returns undefined when no row is found, so update the tests to check against
undefined (or use strict non-null checks) for the variables produced by
findFirst and optional fields: replace storedPasskey !== null with storedPasskey
!== undefined (or a truthy check), replace usedChallenge?.usedAt !== null with
usedChallenge?.usedAt !== undefined, and replace storedChallenge !== null with
storedChallenge !== undefined — locate these variables (storedPasskey,
usedChallenge, storedChallenge) in passkey-service.test.ts and update the three
assertions accordingly.

In `@packages/lib/src/auth/passkey-service.ts`:
- Around line 832-870: Wrap the two independent inserts (the users insert and
the passkeys insert that use userId, passkeyId, credential.id, publicKeyBase64,
etc.) inside a single Drizzle transaction so they commit or rollback together;
use db.transaction (or the project's transaction helper) to execute both inserts
atomically, preserve the existing unique-constraint detection logic (returning {
ok: false, error: { code: 'EMAIL_EXISTS' } } when the email conflict is
detected) and let other errors propagate after the transaction rolls back to
avoid creating an orphan user with no passkey.
🧹 Nitpick comments (6)
packages/db/src/schema/auth.ts (1)

239-242: Redundant index on credentialId.

The .unique() constraint on credentialId (line 230) already creates a unique index in PostgreSQL. The explicit credentialIdx index on line 241 is redundant and adds unnecessary overhead on writes.

♻️ Suggested fix
 }, (table) => ({
   userIdx: index('passkeys_user_id_idx').on(table.userId),
-  credentialIdx: index('passkeys_credential_id_idx').on(table.credentialId),
 }));
packages/lib/src/auth/passkey-service.test.ts (1)

815-816: signupEmail is evaluated once at describe scope — shared across all tests in the block.

Date.now() is called when the describe block is parsed, not per test. All tests within generateRegistrationOptionsForSignup and verifySignupRegistration share the same email value. This is fine for isolation within a single run, but if the describe blocks run in parallel or are revisited, collisions are possible. A minor point — moving the assignment into beforeEach would improve robustness.

Also applies to: 924-925

packages/lib/src/auth/passkey-service.ts (2)

43-48: z.any() used for WebAuthn response fields violates the "never use any" guideline.

Three schemas use z.any() (Lines 45, 55, 82). The PR discussion acknowledges this is intentional to avoid duplicating @simplewebauthn/server's validation. Consider using z.unknown() instead — it provides the same pass-through behavior but results in an unknown type rather than any, which is safer and consistent with the coding guidelines.

Proposed fix
 const verifyRegSchema = z.object({
   userId: z.string().min(1),
-  response: z.any(), // RegistrationResponseJSON validated by simplewebauthn
+  response: z.unknown(), // RegistrationResponseJSON validated by simplewebauthn
   expectedChallenge: z.string().min(1),
   name: z.string().max(PASSKEY_CONFIG.maxNameLength).optional(),
 });

Apply the same change at Lines 55 and 82.

As per coding guidelines: **/*.{ts,tsx}: "Never use any types - always use proper TypeScript types".

Also applies to: 54-57, 79-88


773-777: Unguarded JSON.parse can throw on corrupted metadata.

If challenge.metadata is a non-null string that isn't valid JSON, this will throw an unhandled exception. While the metadata is written by generateRegistrationOptionsForSignup using JSON.stringify, defensive parsing would be more robust.

Proposed fix
-  const metadata = challenge.metadata ? JSON.parse(challenge.metadata as string) : {};
+  let metadata: { email?: string; name?: string } = {};
+  try {
+    metadata = challenge.metadata ? JSON.parse(challenge.metadata as string) : {};
+  } catch {
+    return { ok: false, error: { code: 'VALIDATION_FAILED', message: 'Invalid challenge metadata' } };
+  }
apps/web/src/components/auth/PasskeySignupButton.tsx (1)

31-36: Component duplicates useWebAuthnSupport hook logic internally.

The PasskeySignupButton component (Lines 31-36) has its own isSupported state with the same useEffect(() => setIsSupported(browserSupportsWebAuthn()), []) pattern that the useWebAuthnSupport hook (Lines 187-194) implements. The component could use its own exported hook to avoid duplication.

Proposed fix
 export function PasskeySignupButton({
   ...
 }: PasskeySignupButtonProps) {
-  const [isSupported, setIsSupported] = useState<boolean | null>(null);
   const [isRegistering, setIsRegistering] = useState(false);
-
-  useEffect(() => {
-    setIsSupported(browserSupportsWebAuthn());
-  }, []);
+  const isSupported = useWebAuthnSupport();

Also applies to: 187-194

apps/web/src/app/auth/signup/page.tsx (1)

374-394: Consider using shadcn Checkbox component for consistency.

The TOS checkbox uses a raw <input type="checkbox"> (Line 376) while the rest of the form uses shadcn/ui components (Input, Button, Label). Using the shadcn Checkbox component would maintain consistent styling and accessibility behavior.

Comment thread apps/web/src/app/api/auth/signup-passkey/options/route.ts
Comment thread apps/web/src/app/api/auth/signup-passkey/route.ts Outdated
Comment thread apps/web/src/app/api/auth/signup-passkey/route.ts
Comment thread packages/lib/src/auth/passkey-service.test.ts
Comment thread packages/lib/src/auth/passkey-service.ts
@2witstudios

Copy link
Copy Markdown
Owner Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

2witstudios and others added 3 commits February 13, 2026 14:09
Wrap signup user+passkey creation in DB transaction for atomicity,
replace z.any() with z.record() for safer validation, deduplicate
CSRF cookie persistence logic, and consolidate CSRF hooks via factory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add no-store Cache-Control headers to all passkey route success
responses. Migrate CSRF and rate-limit logging from loggers.auth.warn
to logSecurityEvent with typed passkey-specific event names for
consistent security dashboard visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add VALIDATION_FAILED error handling in signup-passkey/options route
- Wrap userAiSettings insert in try-catch to prevent 500 on failure
- Mask PII (email, name) before passing to trackAuthEvent
- Fix test assertions: use undefined check instead of null for findFirst

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant