Files
nick-doc/04 - Flows/Passkey (WebAuthn) Flow.md
Siavash Sameni a1f056e6a5 docs: align flow docs with code reality + create 35 implementation issue files
Flow docs updated (11 files):
- Delivery Confirmation: reversed actor roles (buyer generates, seller verifies),
  fixed endpoint paths (/delivery-code/generate, /delivery-code/verify)
- Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server
  attestation is implemented; refresh tokens are persisted
- Dispute: corrected resolve schema (action enum), removed non-existent statuses,
  documented security gaps (no role guards on status/resolve/assign), route shadowing,
  all socket events are TODO stubs
- Seller Offer: corrected all endpoint paths, removed 'active' status, documented
  withdraw dead code, missing seller history page, select-offer notification gap
- Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup,
  added unread-count-update socket event
- Authentication: corrected rate limiter (counts all attempts), axios 403 not handled,
  deleteAccount wrong endpoint bug, changePassword no UI
- Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on
  reset-with-code vs token reset
- Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk,
  PaymentProvider type gap, getProviderIntentEndpoint routing bug
- Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths
- Purchase Request: added pending_payment/active statuses, fixed sellers/attachments
  endpoints, corrected socket events, PUT→PATCH bug
- Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap

Issues created (35 files in Issues/):
- 9 security issues (critical) including: dispute privilege escalation ×4,
  unauthenticated payment/scanner endpoints ×2, SIM_ production bypass,
  confirm-delivery ownership gap
- 26 additional major/critical bugs covering broken endpoints, missing features,
  data integrity gaps, and frontend-backend mismatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:47:49 +04:00

11 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Passkey (WebAuthn) Flow
flow
auth
passkey
webauthn
passwordless
User
POST /api/auth/passkey/register/challenge
POST /api/auth/passkey/register
POST /api/auth/passkey/authenticate/challenge
POST /api/auth/passkey/authenticate
GET /api/auth/passkey/list
DELETE /api/auth/passkey/:passkeyId

Passkey (WebAuthn) Flow

Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)

Passwordless sign-in using WebAuthn / Passkeys. The backend issues challenges, cryptographically validates attestations and assertions via @simplewebauthn/server, stores credential metadata under User.passkeys[], and finally issues the same JWT pair as the password flow.

Actors

  • User with a WebAuthn-capable authenticator (Touch ID, Face ID, Windows Hello, Android biometric, YubiKey).
  • Browser WebAuthn APInavigator.credentials.create() / .get().
  • Frontendfrontend/src/auth/components/PasskeyManagement.tsx (registration UI on the account settings page) and frontend/src/auth/components/PasskeySignIn.tsx (sign-in entry).
  • Backendbackend/src/services/auth/passkeyService.ts and the routes in backend/src/services/auth/passkeyRoutes.ts.
  • MongoDBUser.passkeys[] subdocument array (id, publicKey, counter, deviceType, deviceName, createdAt).

Preconditions

  • The browser supports WebAuthn (window.PublicKeyCredential). The frontend checks this and throws "WebAuthn در این مرورگر پشتیبانی نمی‌شود" otherwise.
  • The relying party ID derives from config.frontendUrlbackend/src/services/auth/passkeyService.ts:36 strips scheme/port to produce the WebAuthn rpId.
  • For registration, the user is already authenticated via password or Google (the /passkey/register/* routes are behind authService.authenticateToken).
  • For sign-in, no auth is required — the authenticator's credential ID identifies the user.
  • Env vars consumed by the frontend (commonly NEXT_PUBLIC_PASSKEY_RP_ID, NEXT_PUBLIC_PASSKEY_RP_NAME, NEXT_PUBLIC_PASSKEY_ORIGIN per the codebase conventions) drive WebAuthn options on the client.
  • Important: next.config.ts rewrites /api/:path* directly to the Express backend. There are no Next.js API route handler files for passkey paths — calls go straight to Express. Configure PASSKEY_RP_ORIGIN (and the corresponding NEXT_PUBLIC_* vars) to the frontend origin so the Express handler and the browser agree on the expected origin during challenge verification.

Registration flow

  1. From /dashboard/account/security, the user opens the Passkey management card and clicks "Add new passkey".
  2. Frontend PasskeyManagement.tsx calls POST /api/auth/passkey/register/challenge (with the bearer access token).
  3. Backend passkeyService.generateRegistrationChallenge(userId) (passkeyService.ts:58-70):
    • crypto.randomBytes(32).toString('base64url') — a 256-bit challenge.
    • Stored in an in-memory Map<challenge, { userId, timestamp }> (5-min TTL via interval cleanup).
    • Returns { challenge, rpId, userVerification: 'preferred', timeout: 60000 }.
  4. Frontend calls navigator.credentials.create({ publicKey: { challenge, rp, user, pubKeyCredParams, ... } }). The browser prompts the authenticator (Touch ID, etc.) and returns a PublicKeyCredential containing id, rawId, response.clientDataJSON, response.attestationObject.
  5. Frontend POSTs POST /api/auth/passkey/register with { challenge, credential }.
  6. Backend passkeyService.verifyRegistration(challenge, credential) (:108-146):
    • Looks up the stored challenge → { userId }. Deletes it (single-use).
    • Loads User.findById(userId).
    • Calls verifyRegistrationResponse() from @simplewebauthn/server, which cryptographically validates the attestation object and extracts the COSE public key.
    • Appends to user.passkeys[]: { id: credential.id, publicKey: Buffer.from(webAuthnCredential.publicKey).toString('base64url'), counter: webAuthnCredential.counter, deviceType, deviceName, createdAt: now }.
    • Saves.
  7. Frontend re-fetches GET /api/auth/passkey/list and renders the new entry.

Authentication flow

  1. From /auth/jwt/sign-in, the user clicks "Sign in with passkey".
  2. Frontend PasskeySignIn.tsx calls POST /api/auth/passkey/authenticate/challenge — note this is a public route (no bearer token).
  3. Backend passkeyService.generateAuthenticationChallengeForSignIn() (:88-105) generates a 32-byte challenge and stores it with userId: 'pending'.
  4. Frontend calls navigator.credentials.get({ publicKey: { challenge, rpId, userVerification: 'preferred' } }). The browser surfaces all matching passkeys for the rpId; the user picks one and approves biometrically.
  5. The authenticator returns a PublicKeyCredential whose response includes clientDataJSON, authenticatorData, signature, userHandle.
  6. Frontend POSTs POST /api/auth/passkey/authenticate with { challenge, assertion }.
  7. Backend passkeyService.verifyAuthentication(challenge, assertion) (:149-272):
    • Confirms the challenge exists (and deletes it).
    • User.findOne({ 'passkeys.id': assertion.id }) — finds the user whose passkey matches the credential ID supplied by the authenticator.
    • Calls verifyAuthenticationResponse() from @simplewebauthn/server, passing the stored base64url-encoded COSE public key. This cryptographically verifies the signature over the authenticator data + client data hash.
    • Updates passkey.counter with the verified counter value returned by the library.
    • Issues JWT access + refresh tokens directly via jwt.sign(...) (:230-248). These are signed by the same config.jwtSecret as authService, so they are interchangeable with password-issued tokens.
    • Persists the refresh token: user.refreshTokens.push(refreshToken); await user.save() (:281-282). The standard /api/auth/refresh-token endpoint will accept passkey-issued tokens.
  8. Response: { success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }.
  9. Frontend stores tokens in localStorage and redirects to the dashboard.

Sequence diagram

sequenceDiagram
    autonumber
    actor U as User
    participant FE as Frontend
    participant W as WebAuthn (Browser)
    participant BE as Backend
    participant DB as MongoDB

    rect rgb(245,247,250)
    Note over U,DB: Registration (user already authenticated)
    U->>FE: Click "Add passkey"
    FE->>BE: POST /api/auth/passkey/register/challenge (Bearer)
    BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
    BE-->>FE: { challenge, rpId, ... }
    FE->>W: navigator.credentials.create({ publicKey })
    W-->>FE: PublicKeyCredential (attestation)
    FE->>BE: POST /api/auth/passkey/register { challenge, credential }
    BE->>BE: verifyRegistrationResponse() — attestation verified\nCOSE public key extracted
    BE->>DB: user.passkeys.push({ id, publicKey (base64url COSE), counter, deviceType })
    BE-->>FE: { success: true }
    end

    rect rgb(245,247,250)
    Note over U,DB: Authentication (no prior session)
    U->>FE: Click "Sign in with passkey"
    FE->>BE: POST /api/auth/passkey/authenticate/challenge (public)
    BE->>BE: generateAuthenticationChallengeForSignIn → store
    BE-->>FE: { challenge, rpId, ... }
    FE->>W: navigator.credentials.get({ publicKey })
    W-->>FE: PublicKeyCredential (assertion)
    FE->>BE: POST /api/auth/passkey/authenticate { challenge, assertion }
    BE->>BE: consume challenge
    BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
    DB-->>BE: user with matching passkey
    BE->>BE: verifyAuthenticationResponse() — signature verified\nagainst stored COSE public key
    BE->>DB: passkey.counter updated\nuser.refreshTokens.push(refreshToken)
    BE->>BE: jwt.sign(access) / jwt.sign(refresh)
    BE-->>FE: { success, user, tokens }
    FE->>FE: localStorage.setItem(tokens)
    FE-->>U: Redirect /dashboard
    end

API calls

Method Endpoint Auth Source
POST /api/auth/passkey/register/challenge Bearer passkeyRoutes.ts:50
POST /api/auth/passkey/register Bearer passkeyRoutes.ts:66
POST /api/auth/passkey/authenticate/challenge Public passkeyRoutes.ts:10
POST /api/auth/passkey/authenticate Public passkeyRoutes.ts:23
GET /api/auth/passkey/list Bearer passkeyRoutes.ts:87
DELETE /api/auth/passkey/:passkeyId Bearer passkeyRoutes.ts:103

Database writes

  • users.passkeys — append on register (stores real base64url-encoded COSE public key), increment counter on each successful auth, splice on delete.
  • users.refreshTokens — the passkey authentication path pushes the new refresh token into user.refreshTokens[] (passkeyService.ts:281-282) and saves the document. Passkey-issued refresh tokens are valid for the standard /api/auth/refresh-token endpoint.

Socket events emitted

  • None directly. The frontend joins the same Socket.IO rooms after login as in Authentication Flow.

Side effects

  • In-memory storedChallenges map: per-instance, not Redis. On a horizontally scaled deployment, the challenge created on instance A can only be verified on instance A. Either pin to a single instance, use sticky sessions, or move to Redis (paymentRedisService-style).
  • Cleanup interval: every 5 minutes, expired challenges (>5 min old) are removed (passkeyService.ts:42-55).

Error / edge cases

  • Browser without WebAuthn → frontend throws localized error before issuing the challenge request.
  • User cancels biometric promptNotAllowedError from the browser; frontend shows "Cancelled" toast.
  • Challenge expired or unknown → backend Invalid or expired challenge (:117). Frontend asks user to retry.
  • Credential ID does not match any user404 Passkey not found (passkeyRoutes.ts:40-44). UX should suggest signing in by email instead.
  • Multi-instance backend (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving storedChallenges to Redis.
  • Replay / cloned authenticatorverifyAuthenticationResponse() from @simplewebauthn/server checks that the new counter is strictly greater than the stored counter and will reject replays.

[!note] Production hardening checklist

  1. Move challenge storage to Redis to support multi-instance deploys.
  2. Add excludeCredentials during registration to prevent re-registering the same passkey.
  3. Ensure PASSKEY_RP_ORIGIN matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express).

Linked flows

Source files

  • Backend: backend/src/services/auth/passkeyService.ts
  • Backend: backend/src/services/auth/passkeyRoutes.ts
  • Frontend: frontend/src/auth/components/PasskeyManagement.tsx
  • Frontend: frontend/src/auth/components/PasskeySignIn.tsx