Skip to content

Commit 3ece86d

Browse files
authored
Merge pull request #38 from Dmong04/Features_S11_Dmong04
feat(webauthn): enforce per-device credential policy and document hybrid auth flow (SRV-046, SRV-047, SRV-048) - S11
2 parents 2b5d0a2 + 822fe84 commit 3ece86d

11 files changed

Lines changed: 706 additions & 9 deletions

.vscode/settings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"files.exclude": {
3+
"**/.git": true,
4+
"**/.svn": true,
5+
"**/.hg": true,
6+
"**/.DS_Store": true,
7+
"**/Thumbs.db": true
8+
}
9+
}

docs/architecture.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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.

src/webauthn/dto/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { RegistrationResponseDto } from './registration-response.dto';
22
export { AuthenticationResponseDto } from './authentication-response.dto';
33
export { AuthenticateOptionsDto } from './authenticate-options.dto';
44
export { RegistrationVerifiedDto } from './webauthn-result.dto';
5+
export { WebAuthnCredentialDto } from './webauthn-credential.dto';

src/webauthn/dto/registration-response.dto.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsObject, IsOptional, IsString } from 'class-validator';
2+
import { IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
33

44
/**
55
* Browser-produced `RegistrationResponseJSON` (SimpleWebAuthn) posted to
@@ -8,6 +8,11 @@ import { IsObject, IsOptional, IsString } from 'class-validator';
88
* `response` / `clientExtensionResults` objects are intentionally left
99
* un-nested-validated so the raw attestation fields pass through untouched to
1010
* `verifyRegistrationResponse`.
11+
*
12+
* `deviceName` is Ding's own addition (SRV-046, per-device credentials
13+
* policy) — it is NOT part of the SimpleWebAuthn response and is stripped
14+
* out by the controller before the rest of the body is forwarded to
15+
* `verifyRegistrationResponse`.
1116
*/
1217
export class RegistrationResponseDto {
1318
@ApiProperty({ description: 'Base64URL credential ID.' })
@@ -39,4 +44,16 @@ export class RegistrationResponseDto {
3944
@IsOptional()
4045
@IsObject()
4146
clientExtensionResults?: Record<string, unknown>;
47+
48+
@ApiPropertyOptional({
49+
description:
50+
'Friendly label for this device (e.g. "iPhone 15"). Shown in the ' +
51+
'passkey management list; not sent to the authenticator.',
52+
example: 'iPhone 15',
53+
maxLength: 60,
54+
})
55+
@IsOptional()
56+
@IsString()
57+
@MaxLength(60)
58+
deviceName?: string;
4259
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
3+
/**
4+
* A single registered passkey, as returned by `GET /v1/webauthn/credentials`.
5+
* Deliberately excludes `credentialId` and `publicKey` — the internal `id` is
6+
* enough for the client to reference the device (e.g. for revocation), and
7+
* neither the raw credential ID nor the public key have any legitimate use in
8+
* a device-management UI.
9+
*/
10+
11+
export class WebAuthnCredentialDto {
12+
@ApiProperty({
13+
format: 'uuid',
14+
description: 'Internal credential record ID - use this to revoke',
15+
})
16+
id!: string;
17+
18+
@ApiPropertyOptional({ example: 'iPhone 15', nullable: true })
19+
deviceName!: string | null;
20+
21+
@ApiProperty({ format: 'date-time' })
22+
createdAt!: string;
23+
24+
@ApiPropertyOptional({ format: 'date-time', nullable: true })
25+
lastUsedAt!: string | null;
26+
}

src/webauthn/webauthn.constants.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ export type ChallengeType =
1818
*/
1919
export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
2020

21+
/**
22+
* Per-device credentials policy (SRV-046).
23+
*
24+
* Ding is a payments app: a passkey is the only thing standing between an
25+
* authenticated Supabase session and moving funds. Two rules follow from that:
26+
*
27+
* 1. Bound enrolment — a user can register at most this many passkeys. This
28+
* limits the blast radius of a compromised or lost device and keeps the
29+
* `webauthn.authenticate/options` `allowCredentials` list small. Kept as a
30+
* constant (not an env var) for the same reason as CHALLENGE_TTL_MS.
31+
* 2. Never zero — a user is never allowed to revoke their last remaining
32+
* credential. Doing so would permanently lock them out of authorizing
33+
* payments (there is no password fallback in the hybrid auth model).
34+
*/
35+
export const MAX_CREDENTIALS_PER_USER = 5;
36+
2137
/**
2238
* Ding error codes surfaced by the WebAuthn flow. Consumed by the global
2339
* HttpExceptionFilter as the `code` field of the error envelope.
@@ -27,4 +43,7 @@ export const WEBAUTHN_ERROR = {
2743
CHALLENGE_EXPIRED: 'WEBAUTHN_CHALLENGE_EXPIRED',
2844
CREDENTIAL_EXISTS: 'WEBAUTHN_CREDENTIAL_EXISTS',
2945
NO_CREDENTIALS: 'WEBAUTHN_NO_CREDENTIALS',
46+
CREDENTIAL_LIMIT_REACHED: 'WEBAUTHN_CREDENTIAL_LIMIT_REACHED',
47+
CREDENTIAL_NOT_FOUND: 'WEBAUTHN_CREDENTIAL_NOT_FOUND',
48+
LAST_CREDENTIAL: 'WEBAUTHN_LAST_CREDENTIAL',
3049
} as const;

0 commit comments

Comments
 (0)