Initial commit: nick docs
This commit is contained in:
162
04 - Flows/Passkey (WebAuthn) Flow.md
Normal file
162
04 - Flows/Passkey (WebAuthn) Flow.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Passkey (WebAuthn) Flow
|
||||
tags: [flow, auth, passkey, webauthn, passwordless]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["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
|
||||
|
||||
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.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User** with a WebAuthn-capable authenticator (Touch ID, Face ID, Windows Hello, Android biometric, YubiKey).
|
||||
- **Browser WebAuthn API** — `navigator.credentials.create()` / `.get()`.
|
||||
- **Frontend** — `frontend/src/auth/components/PasskeyManagement.tsx` (registration UI on the account settings page) and `frontend/src/auth/components/PasskeySignIn.tsx` (sign-in entry).
|
||||
- **Backend** — `backend/src/services/auth/passkeyService.ts` and the routes in `backend/src/services/auth/passkeyRoutes.ts`.
|
||||
- **MongoDB** — `User.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.frontendUrl` — `backend/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.
|
||||
|
||||
## 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)`.
|
||||
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', 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"**.
|
||||
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.
|
||||
- `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.
|
||||
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
|
||||
9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
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
|
||||
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
||||
BE->>BE: verifyRegistration → consume challenge
|
||||
BE->>DB: user.passkeys.push({ id, 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->>DB: passkey.counter += 1
|
||||
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, 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.
|
||||
|
||||
## 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 prompt** → `NotAllowedError` 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 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.
|
||||
|
||||
> [!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[]`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — token semantics are identical post-issuance.
|
||||
- [[Registration Flow]] — passkey is an additional credential, not a replacement for initial account creation.
|
||||
|
||||
## 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`
|
||||
Reference in New Issue
Block a user