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>
This commit is contained in:
@@ -7,7 +7,9 @@ related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/pass
|
||||
|
||||
# Passkey (WebAuthn) Flow
|
||||
|
||||
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, validates signed assertions, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
|
||||
|
||||
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
|
||||
|
||||
@@ -24,6 +26,7 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
||||
- 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
|
||||
|
||||
@@ -38,13 +41,11 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
||||
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
|
||||
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
|
||||
- Loads `User.findById(userId)`.
|
||||
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }`.
|
||||
- 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.
|
||||
|
||||
> [!warning] Attestation validation is stubbed
|
||||
> `passkeyService.verifyRegistration` currently **does not** parse the attestation object or extract the real COSE public key — see the comment block at `passkeyService.ts:122-128` ("In a real implementation, you would..."). The `publicKey` field is the literal string `'simulated-public-key'`. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use `@simplewebauthn/server` to parse attestation and store the verified public key.
|
||||
|
||||
## Authentication flow
|
||||
|
||||
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
|
||||
@@ -56,8 +57,10 @@ Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenge
|
||||
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.
|
||||
- `passkey.counter += 1` (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one).
|
||||
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). Note: these are signed by the same `config.jwtSecret` as in `authService`, so they are interchangeable with password-issued tokens.
|
||||
- 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.
|
||||
|
||||
@@ -79,10 +82,10 @@ sequenceDiagram
|
||||
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
|
||||
BE-->>FE: { challenge, rpId, ... }
|
||||
FE->>W: navigator.credentials.create({ publicKey })
|
||||
W-->>FE: PublicKeyCredential
|
||||
W-->>FE: PublicKeyCredential (attestation)
|
||||
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
||||
BE->>BE: verifyRegistration → consume challenge
|
||||
BE->>DB: user.passkeys.push({ id, counter, deviceType })
|
||||
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
|
||||
|
||||
@@ -98,7 +101,8 @@ sequenceDiagram
|
||||
BE->>BE: consume challenge
|
||||
BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
|
||||
DB-->>BE: user with matching passkey
|
||||
BE->>DB: passkey.counter += 1
|
||||
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)
|
||||
@@ -119,8 +123,8 @@ sequenceDiagram
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users.passkeys`** — append on register, increment `counter` on each successful auth, splice on delete.
|
||||
- A new refresh token is **not** appended to `user.refreshTokens` in the current passkey path (the JWT is signed directly without round-tripping through `authService.generateRefreshToken`). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases.
|
||||
- **`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
|
||||
|
||||
@@ -138,16 +142,12 @@ sequenceDiagram
|
||||
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
|
||||
- **Credential ID does not match any user** → `404 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** — current implementation does not strictly enforce monotonic counter; revisit before production.
|
||||
- **Refresh-token rotation gap** — passkey-issued refresh tokens are not added to `user.refreshTokens[]`. The standard `/api/auth/refresh-token` will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through `authService.generateRefreshToken` and persist them.
|
||||
- **Replay / cloned authenticator** — `verifyAuthenticationResponse()` from `@simplewebauthn/server` checks that the new counter is strictly greater than the stored counter and will reject replays.
|
||||
|
||||
> [!warning] Production hardening checklist
|
||||
> 1. Replace stub attestation parsing with `@simplewebauthn/server`.
|
||||
> 2. Persist the COSE public key, not a stub string.
|
||||
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not).
|
||||
> 4. Move challenge storage to Redis to support multi-instance deploys.
|
||||
> 5. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
|
||||
> 6. Push the passkey-issued refresh token into `user.refreshTokens[]`.
|
||||
> [!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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user