Raxx · internal docs

internal · gated ↑ index

Multi-user Authentication & GDPR Architecture

Status: Draft Owner: software-architect Last updated: 2026-04-21 Parent epic: #78 — Exploration Platform for Individual Traders Related ADRs: 0001, 0002, 0003

1. Context

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.

2. Invariants (non-negotiable)

  1. No stored credentials, ever — not hashed, not encrypted. The backend must be incapable of replaying a user-secret. WebAuthn public keys + credential IDs are not credentials in this sense (they cannot be used to impersonate the user without the matching private key held in the authenticator hardware).
  2. Passkeys / WebAuthn only. No password, no SMS OTP, no email OTP as the auth factor. Platform authenticators (Face ID, Touch ID, Windows Hello) and roaming authenticators (YubiKey, Titan) only.
  3. Email is the single contact channel, and only after a verification round-trip. No phone, no SMS, no push.
  4. GDPR by default — DSAR (access/portability/erasure/rectification), documented retention, automated breach-notification pipeline, DPA-ready audit logging.
  5. No U/P path exists in the codebase. No hidden admin backdoor, no password fallback, no "temporary" bypass. Enforced by schema constraint + code-review checklist + CI grep.

If any piece of the design below reads as violating an invariant, treat it as a bug in the doc.

3. Data model

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.

4. APIs / contracts

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.

GDPR / DSR endpoints (/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.

Admin

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).

5. Sequences

5.1 Registration

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."

5.2 Login (discoverable)

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}

5.3 Recovery (primary passkey lost)

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).

6. Session & API auth

7. Multi-device

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.

8. Security & breach handling

9. Migrations & rollout

  1. Migration 0001_auth_identity.sql — creates the five tables. No data migration (new capability).
  2. Dark launch: endpoints deployed behind feature flag AUTH_V1=off. Integration tests run against the flag-on variant.
  3. Internal beta: flag on for a fixed allowlist of emails. Exercise registration, multi-device, recovery, DSR.
  4. GA: flag on for all; legacy single-operator bypass removed in the same PR that flips GA.
  5. Rollback: flag-off restores prior behavior; data remains but is unreferenced.

10. Open questions (require user decision)

  1. Mail provider — SendGrid, SES, or Postmark? Cost, deliverability, and DPA terms differ. Blocks the email-verification sub-card.
  2. WebAuthn RP ID scope — is the production origin app.trademaster.example or a different domain? RP ID is effectively immutable once users are registered.
  3. Admin bootstrap — how is the first admin created? Proposed: CLI-only one-shot python cli.py admin-bootstrap --email=... that requires DB file access and self-destructs the command after one success.
  4. Retention period for paper-trading history — GDPR requires a defined period. Propose 3 years; needs user confirmation.
  5. Hosting region — GDPR data-residency expectation (EU-only? US OK?) drives mail provider region and backup storage region.

End of doc. See ADRs 0001–0003 for the decisions behind the choices above.