|
| 1 | +# Architecture — Hybrid Auth (Supabase + WebAuthn) |
| 2 | + |
| 3 | +> Server-side reference for how Ding Payments authenticates a *session* and |
| 4 | +> authorizes a *payment*. Covers SRV-041–048 (S10/S11). For the full system |
| 5 | +> design, data model, and API surface, see [server-build-plan.md](./server-build-plan.md). |
| 6 | +
|
| 7 | +## 1. Why two auth mechanisms |
| 8 | + |
| 9 | +Ding is self-custodial: the server never holds a user's Stellar private key. |
| 10 | +It still needs to be sure that (a) a request comes from a logged-in user, and |
| 11 | +(b) a *payment* is being approved by the physical owner of that user's phone, |
| 12 | +not just anyone with a valid session token. One mechanism handles each job: |
| 13 | + |
| 14 | +| Layer | Mechanism | Answers | Scope | |
| 15 | +|-------|-----------|---------|-------| |
| 16 | +| Session | Supabase JWT | "Is this a logged-in user?" | Every request (except `@Public()` routes) | |
| 17 | +| Payment approval | WebAuthn (passkey) | "Did the device owner just approve *this* payment?" | Only `POST /v1/payments/:id/authorize` | |
| 18 | + |
| 19 | +A stolen or replayed JWT is enough to *read* data, but never enough to move |
| 20 | +funds — that always requires a fresh, payment-scoped passkey assertion. |
| 21 | + |
| 22 | +## 2. Session layer — Supabase JWT |
| 23 | + |
| 24 | +- `SupabaseAuthGuard` is registered globally (`APP_GUARD` in `app.module.ts`); |
| 25 | + every route is protected unless annotated `@Public()`. |
| 26 | +- The guard validates the bearer token against `SUPABASE_JWT_SECRET` and |
| 27 | + attaches `{ supabaseUserId, email }` to `request.user`, retrievable with the |
| 28 | + `@CurrentUser()` decorator. |
| 29 | +- Each protected service resolves the *internal* Ding user from |
| 30 | + `authUser.supabaseUserId` via `UsersService.getUserBySupabaseId()` — the |
| 31 | + Supabase ID is never used directly as a foreign key elsewhere. |
| 32 | +- `@Public()` routes: `POST /v1/payment-requests/validate`, `GET /health*`. |
| 33 | + |
| 34 | +This layer alone is what the client uses for every non-payment call (fetch |
| 35 | +profile, list transactions, validate an NFC payload). |
| 36 | + |
| 37 | +## 3. Payment-approval layer — WebAuthn |
| 38 | + |
| 39 | +### 3.1 Registering a passkey (once per device) |
| 40 | + |
| 41 | +```mermaid |
| 42 | +sequenceDiagram |
| 43 | + participant Client |
| 44 | + participant Server |
| 45 | + participant DB as Postgres |
| 46 | +
|
| 47 | + Client->>Server: POST /v1/webauthn/register/options (JWT) |
| 48 | + Server->>DB: count existing credentials for user |
| 49 | + alt at MAX_CREDENTIALS_PER_USER |
| 50 | + Server-->>Client: 409 WEBAUTHN_CREDENTIAL_LIMIT_REACHED |
| 51 | + else under limit |
| 52 | + Server->>DB: store WebAuthnChallenge (type=registration, TTL 5m) |
| 53 | + Server-->>Client: PublicKeyCredentialCreationOptionsJSON |
| 54 | + Client->>Client: navigator.credentials.create() |
| 55 | + Client->>Server: POST /v1/webauthn/register/verify { ...attestation, deviceName? } |
| 56 | + Server->>Server: verifyRegistrationResponse() |
| 57 | + Server->>DB: consume challenge, store WebAuthnCredential |
| 58 | + Server-->>Client: 200 { verified: true, credentialId } |
| 59 | + end |
| 60 | +``` |
| 61 | + |
| 62 | +### 3.2 Authorizing a payment (every payment) |
| 63 | + |
| 64 | +```mermaid |
| 65 | +sequenceDiagram |
| 66 | + participant Sender as Client (sender) |
| 67 | + participant Server |
| 68 | + participant DB as Postgres |
| 69 | +
|
| 70 | + Sender->>Server: POST /v1/webauthn/authenticate/options { paymentId } (JWT) |
| 71 | + Server->>DB: store WebAuthnChallenge (type=authentication, paymentId, TTL 5m) |
| 72 | + Server-->>Sender: PublicKeyCredentialRequestOptionsJSON |
| 73 | + Sender->>Sender: navigator.credentials.get() |
| 74 | + Sender->>Server: POST /v1/payments/:id/authorize { ...assertion } (JWT) |
| 75 | + Server->>Server: verify sender owns payment, status = CREATED, not expired |
| 76 | + Server->>Server: verifyAuthenticationResponse() [WebAuthnService] |
| 77 | + Server->>DB: consume challenge, advance credential counter |
| 78 | + Server->>DB: payment.status CREATED → AUTHORIZED |
| 79 | + Server-->>Sender: 200 { id, status: AUTHORIZED, authorizedAt } |
| 80 | +``` |
| 81 | + |
| 82 | +The challenge for step 3.2 is bound to a specific `paymentId` |
| 83 | +(`WebAuthnChallenge.paymentId`), so an assertion generated for one payment |
| 84 | +cannot be replayed against another. |
| 85 | + |
| 86 | +### 3.3 Guards enforced before verification (`PaymentsService.authorize`) |
| 87 | + |
| 88 | +| Check | Failure | HTTP | |
| 89 | +|-------|---------|------| |
| 90 | +| Payment exists | `PAYMENT_NOT_FOUND` | 404 | |
| 91 | +| Caller is the payment's sender | `FORBIDDEN` | 403 | |
| 92 | +| Payment status is `CREATED` | `PAYMENT_INVALID_STATE` | 409 | |
| 93 | +| Payment not expired | `PAYMENT_INVALID_STATE` | 409 | |
| 94 | +| WebAuthn assertion valid | `WEBAUTHN_VERIFICATION_FAILED` / `WEBAUTHN_CHALLENGE_EXPIRED` | 400 | |
| 95 | + |
| 96 | +Only after all five pass does the payment transition to `AUTHORIZED` and emit |
| 97 | +`payment.authorized`. |
| 98 | + |
| 99 | +## 4. Per-device credentials policy (SRV-046) |
| 100 | + |
| 101 | +A passkey is the only thing standing between a valid session and moving |
| 102 | +funds — there is no password fallback. Two rules follow directly from that: |
| 103 | + |
| 104 | +1. **Bounded enrolment.** A user may register at most `MAX_CREDENTIALS_PER_USER` |
| 105 | + (5) passkeys. Registering past the limit is rejected at the |
| 106 | + `register/options` step with `409 WEBAUTHN_CREDENTIAL_LIMIT_REACHED`, |
| 107 | + before any authenticator ceremony starts. This keeps the |
| 108 | + `allowCredentials` list sent to authenticators small and bounds the impact |
| 109 | + of a single compromised or lost device. |
| 110 | +2. **Never zero.** A user may never revoke their last remaining passkey. |
| 111 | + `DELETE /v1/webauthn/credentials/:id` rejects with |
| 112 | + `409 WEBAUTHN_LAST_CREDENTIAL` when the target is the user's only |
| 113 | + credential — doing otherwise would permanently lock them out of |
| 114 | + authorizing payments. |
| 115 | + |
| 116 | +Both limits are kept as source constants (`webauthn.constants.ts`) rather |
| 117 | +than env vars, consistent with `CHALLENGE_TTL_MS` — they are security |
| 118 | +invariants, not per-environment tuning knobs. |
| 119 | + |
| 120 | +### 4.1 Device management endpoints |
| 121 | + |
| 122 | +| Method | Route | Auth | Description | |
| 123 | +|--------|-------|------|-------------| |
| 124 | +| `POST` | `/v1/webauthn/register/options` | JWT | Start registration; 409 at the cap | |
| 125 | +| `POST` | `/v1/webauthn/register/verify` | JWT | Complete registration; optional `deviceName` | |
| 126 | +| `GET` | `/v1/webauthn/credentials` | JWT | List the caller's registered passkeys | |
| 127 | +| `DELETE` | `/v1/webauthn/credentials/:id` | JWT | Revoke a passkey; 409 if it's the last one | |
| 128 | +| `POST` | `/v1/webauthn/authenticate/options` | JWT | Start a payment-scoped authentication challenge | |
| 129 | +| `POST` | `/v1/payments/:id/authorize` | JWT | Verify assertion, transition `CREATED → AUTHORIZED` | |
| 130 | + |
| 131 | +`GET /v1/webauthn/credentials` never returns `credentialId` or the raw public |
| 132 | +key — only `{ id, deviceName, createdAt, lastUsedAt }`, where `id` is the |
| 133 | +internal record ID used for revocation. This keeps the device-management UI |
| 134 | +free of any value that could be replayed in an authentication ceremony. |
| 135 | + |
| 136 | +## 5. Anti-clone protection |
| 137 | + |
| 138 | +Every `WebAuthnCredential` stores a `counter` (from the authenticator). On |
| 139 | +each successful payment authorization the server updates it to |
| 140 | +`authenticationInfo.newCounter` and stamps `lastUsedAt`. `verifyAuthenticationResponse` |
| 141 | +rejects an assertion whose counter does not strictly increase, which is the |
| 142 | +standard signal a credential has been cloned (two authenticators |
| 143 | +independently incrementing the same counter will diverge). This makes |
| 144 | +`lastUsedAt` a genuine "this device was last used to approve a payment on |
| 145 | +{date}" signal in the device-management list, not just a login timestamp. |
| 146 | + |
| 147 | +## 6. Error codes (WebAuthn scope) |
| 148 | + |
| 149 | +| Code | HTTP | Meaning | |
| 150 | +|------|------|---------| |
| 151 | +| `WEBAUTHN_VERIFICATION_FAILED` | 400 | Attestation/assertion failed cryptographic verification, or referenced an unknown/foreign credential | |
| 152 | +| `WEBAUTHN_CHALLENGE_EXPIRED` | 400 | No matching, unexpired challenge for this user (and payment, for authentication) | |
| 153 | +| `WEBAUTHN_CREDENTIAL_EXISTS` | 409 | Duplicate `credentialId` on registration (Prisma `P2002`) | |
| 154 | +| `WEBAUTHN_NO_CREDENTIALS` | 400 | User has no registered passkeys — cannot authenticate | |
| 155 | +| `WEBAUTHN_CREDENTIAL_LIMIT_REACHED` | 409 | User is at `MAX_CREDENTIALS_PER_USER` | |
| 156 | +| `WEBAUTHN_CREDENTIAL_NOT_FOUND` | 404 | Revocation target doesn't exist or isn't owned by the caller | |
| 157 | +| `WEBAUTHN_LAST_CREDENTIAL` | 409 | Attempted to revoke the user's only remaining passkey | |
| 158 | + |
| 159 | +All error bodies follow the global envelope from `HttpExceptionFilter`: |
| 160 | +`{ statusCode, message, code, errors, timestamp, path }`. |
| 161 | + |
| 162 | +## 7. Configuration |
| 163 | + |
| 164 | +| Variable | Required | Notes | |
| 165 | +|----------|----------|-------| |
| 166 | +| `WEBAUTHN_RP_ID` | Yes | Relying Party ID (domain), must match the client origin's host | |
| 167 | +| `WEBAUTHN_RP_NAME` | Yes | Human-readable RP name shown by the authenticator UI | |
| 168 | +| `WEBAUTHN_ORIGIN` | Yes | Full expected origin (scheme + host [+ port]) | |
| 169 | + |
| 170 | +`CHALLENGE_TTL_MS` (5 minutes) and `MAX_CREDENTIALS_PER_USER` (5) are code |
| 171 | +constants in `src/webauthn/webauthn.constants.ts`, not environment variables |
| 172 | +— see §4. |
| 173 | + |
| 174 | +## 8. Testing strategy |
| 175 | + |
| 176 | +- **Unit** (`src/webauthn/webauthn.service.spec.ts`): mocks |
| 177 | + `@simplewebauthn/server` and `WebAuthnRepository`; covers registration |
| 178 | + (incl. the credential cap and `deviceName` persistence), authentication |
| 179 | + options, payment-assertion verification (incl. an assertion presented for a |
| 180 | + *different* user's credential), and device management |
| 181 | + (`listCredentials`, `revokeCredential`, including the "last credential" |
| 182 | + guard). |
| 183 | +- **E2E** (`test/webauthn-payments.e2e-spec.ts`): boots the real `AppModule` |
| 184 | + with `PrismaService` and `WebAuthnService` mocked, and a real Supabase JWT |
| 185 | + signed with the test secret — so the actual `SupabaseAuthGuard`, DTO |
| 186 | + validation (`whitelist` + `forbidNonWhitelisted`), and HTTP status/error |
| 187 | + mapping run unmodified. Exercises the full device-management surface and |
| 188 | + the `payments/:id/authorize` state guards. |
| 189 | +- The real WebAuthn ceremony (browser ↔ authenticator) is out of scope for |
| 190 | + automated tests here; `@simplewebauthn/server`'s own test suite covers the |
| 191 | + cryptographic verification logic that Ding depends on. |
0 commit comments