Status: Draft Owner: software-architect Last updated: 2026-04-21 Parent epic: #78 — Exploration Platform for Individual Traders Related ADRs: 0001, 0002, 0003
TradeMasterAPI is moving from a single-operator developer tool to a multi-user platform where individual retail traders log in, hold paper portfolios, and eventually route real orders. That step introduces identity, session, and data-protection surface area the current codebase does not have. This document specifies the authentication and GDPR-compliance architecture the backend (Raptor, i.e. backend_v2/) and frontend (Antlers, i.e. frontend/trademaster_ui/) must implement.
The design is deliberately narrow: it decides boundaries and invariants, not library versions.
If any piece of the design below reads as violating an invariant, treat it as a bug in the doc.
New SQLite tables in backend_v2/db/ (migration lives in backend_v2/db/migrations/).
users
id TEXT PK (uuid v4)
email TEXT UNIQUE NOT NULL
email_verified_at TIMESTAMP NULL
display_name TEXT NULL
role TEXT NOT NULL DEFAULT 'user' -- 'user' | 'admin'
created_at TIMESTAMP NOT NULL
deleted_at TIMESTAMP NULL -- soft-delete for DSR erasure audit
CHECK (role IN ('user','admin'))
webauthn_credentials
id TEXT PK (credential_id, base64url)
user_id TEXT FK -> users.id ON DELETE CASCADE
public_key BLOB NOT NULL -- COSE public key, NOT a secret
sign_count INTEGER NOT NULL DEFAULT 0
transports TEXT NULL -- csv: 'usb,nfc,internal'
aaguid TEXT NULL
device_label TEXT NULL -- user-supplied: "MacBook", "YubiKey blue"
created_at TIMESTAMP NOT NULL
last_used_at TIMESTAMP NULL
backup_eligible BOOLEAN NOT NULL DEFAULT 0
backup_state BOOLEAN NOT NULL DEFAULT 0
-- Explicit schema-level ban on secrets:
CHECK (length(public_key) > 0)
email_verifications
id TEXT PK
user_id TEXT FK -> users.id ON DELETE CASCADE
email TEXT NOT NULL -- the email being verified (may differ during rectification)
token_hash TEXT NOT NULL -- SHA-256 of single-use link token; the token itself is never stored
expires_at TIMESTAMP NOT NULL -- 15 min
consumed_at TIMESTAMP NULL
purpose TEXT NOT NULL -- 'initial' | 'recovery' | 'rectification'
created_at TIMESTAMP NOT NULL
sessions
id TEXT PK -- random 256-bit, stored hashed
user_id TEXT FK -> users.id ON DELETE CASCADE
credential_id TEXT FK -> webauthn_credentials.id -- binds session to the passkey that auth'd
issued_at TIMESTAMP NOT NULL
expires_at TIMESTAMP NOT NULL -- 12h rolling
revoked_at TIMESTAMP NULL
user_agent TEXT NULL
ip_prefix TEXT NULL -- /24 for IPv4, /48 for IPv6 (minimized)
audit_log
id INTEGER PK AUTOINCREMENT
actor_user_id TEXT NULL -- null = system
action TEXT NOT NULL -- 'user.register', 'session.issue', 'dsr.export', ...
target_kind TEXT NULL -- 'user' | 'credential' | 'session' | 'order'
target_id TEXT NULL
context TEXT NULL -- JSON, redacted
at TIMESTAMP NOT NULL
-- retention: 2 years (DPA requirement); see ADR-0003
What is deliberately absent: any column that could store a password, TOTP seed, recovery code, SMS number, or phone. The absence is enforced by a CI grep (scripts/ci/check_no_credential_fields.sh) that fails the build if these column names appear in any migration.
All auth endpoints live under /api/auth/* in Raptor. All return JSON. CSRF is handled via same-site cookies + origin check; WebAuthn challenges are one-shot and bound to a server-side session.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/register/options |
Begin registration. Body: {email, display_name}. Returns PublicKeyCredentialCreationOptions. Server holds challenge in a short-lived cache (60s). |
| POST | /api/auth/register/verify |
Body: attestation response. Server verifies via py-webauthn, creates users row (unverified), webauthn_credentials row, and dispatches email verification. Returns {user_id, needs_email_verification: true}. |
| GET | /api/auth/email/verify?token=... |
Consumes the single-use token, sets email_verified_at. No auto-login (user must next complete WebAuthn login). |
| POST | /api/auth/login/options |
Usernameless / discoverable. Returns PublicKeyCredentialRequestOptions with empty allowCredentials. |
| POST | /api/auth/login/verify |
Body: assertion response. On success issues session cookie + returns {user_id, role}. |
| POST | /api/auth/logout |
Revokes current session. |
| POST | /api/auth/session/refresh |
Rolls a session within its rolling window; requires a fresh user-verified WebAuthn assertion every 24h (step-up). |
| POST | /api/auth/credentials/add/options |
Add a second device. Requires existing authenticated session. |
| POST | /api/auth/credentials/add/verify |
Completes second-device registration. |
| GET | /api/auth/credentials |
List the caller's passkeys (label, created_at, last_used_at). |
| DELETE | /api/auth/credentials/{id} |
Remove a passkey. The last remaining passkey cannot be removed without going through recovery. |
| POST | /api/auth/recovery/start |
Body: {email}. Always 202 (no account enumeration). Sends a single-use recovery link to the verified email. |
| POST | /api/auth/recovery/complete |
Consumes recovery token, walks user through new-passkey attestation. Revokes all prior credentials + sessions on success. |
/api/gdpr/*, authenticated)| Method | Path | Purpose |
|---|---|---|
| POST | /api/gdpr/export |
Async. Enqueues a per-user bundle (JSON + CSV). Notifies by email when ready. Link expires in 7 days. |
| POST | /api/gdpr/erase |
Async. Requires fresh WebAuthn step-up. Soft-deletes immediately, purges PII after 30-day cooling period, retains audit rows with hashed actor id for 2 years (legal obligation). |
| PATCH | /api/gdpr/profile |
Rectify display_name or initiate email-change flow (re-verification required). |
| GET | /api/gdpr/retention |
Returns the user-visible retention table. |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/users |
List; admin only. Returns minimal PII. |
| GET | /api/admin/audit |
Query audit_log; admin only; every read itself written to audit_log. |
| POST | /api/admin/breach/notify |
Triggers the breach-notification runbook (see §8). |
sequenceDiagram
participant U as User
participant A as Antlers (UI)
participant R as Raptor (API)
participant M as Mail provider
U->>A: Enter email, click "Create account"
A->>R: POST /api/auth/register/options {email}
R->>R: stash challenge (60s)
R-->>A: PublicKeyCredentialCreationOptions
A->>U: Browser prompts for Face ID / YubiKey
U-->>A: Attestation
A->>R: POST /api/auth/register/verify {attestation}
R->>R: py-webauthn verify, insert users + webauthn_credentials
R->>M: send verification email (one-shot token)
R-->>A: {user_id, needs_email_verification:true}
M-->>U: Email link
U->>R: GET /api/auth/email/verify?token=...
R->>R: set email_verified_at, consume token
R-->>U: "Verified. Please sign in."
sequenceDiagram
participant U as User
participant A as Antlers
participant R as Raptor
U->>A: Click "Sign in"
A->>R: POST /api/auth/login/options
R-->>A: PublicKeyCredentialRequestOptions (allowCredentials:[])
A->>U: Browser shows passkey picker
U-->>A: Assertion
A->>R: POST /api/auth/login/verify {assertion}
R->>R: verify signature, update sign_count + last_used_at
R->>R: issue session (12h rolling)
R-->>A: Set-Cookie session=...; {user_id, role}
Email is the fallback channel — the only one. User hits "I lost my device", enters email, receives a single-use 15-minute link. Clicking it drops them into a new-passkey registration flow whose success atomically (a) attests a new credential, (b) revokes all existing credentials and sessions, (c) writes an audit row user.credentials_reset. The email-only gate is acknowledged as the weakest link and mitigated by inbox rate-limit, visible "we just reset your passkeys" notification, and a 72-hour cooldown before any new outbound trade can be placed from a newly recovered account (paper-first still gates live trading independently).
HttpOnly, Secure, SameSite=Strict, 12-hour rolling, bound server-side to the credential that produced them (sessions.credential_id)./api/auth/api-token. Tokens are opaque, bound to the session id, and revoked when the session is.After login, POST /api/auth/credentials/add/options issues a registration challenge scoped to the already-authenticated user. The new device goes through normal WebAuthn attestation. Users can label devices ("MacBook", "Work YubiKey"). We recommend — but do not require — at least two registered devices; the UI nags on single-credential accounts.
/register/*, /recovery/*, /email/verify. Bucket defined in backend_v2/api/middleware/rate_limiter.py config./recovery/start always returns 202; /register/options returns generic "check your email" on duplicates.audit_log with action prefixed breach.* triggers a GitHub Action that pages on-call, files an Incident issue with severity:critical, and starts the 72-hour GDPR Art. 33 clock. Runbook lives in docs/agents/security_response.md (extend it, don't duplicate here).WEBAUTHN_RP_ID, WEBAUTHN_ORIGIN, MAIL_PROVIDER_KEY — all env vars, rotatable without redeploy via the secret store.AUTH_DISABLED=1 env flag short-circuits all /api/auth/* to 503 and logs every attempt, for use during an active incident.0001_auth_identity.sql — creates the five tables. No data migration (new capability).AUTH_V1=off. Integration tests run against the flag-on variant.app.trademaster.example or a different domain? RP ID is effectively immutable once users are registered.python cli.py admin-bootstrap --email=... that requires DB file access and self-destructs the command after one success.End of doc. See ADRs 0001–0003 for the decisions behind the choices above.