Prima Get an API key

Module kit / Identity and auth

Clinic staff and patient auth

identity-auth/clinic-staff-and-patient-auth

Kind backend
Domain identity-auth
Maturity staging
Reusable extract
Stack fastapi, postgres
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern
Frontends identity-auth/ui/auth-screens
Reference implementation
  • PrimaOS/backend/app/routers/portal.py
  • PrimaOS/backend/app/routers/auth_link.py
  • PrimaOS/backend/app/routers/stubs.py
  • PrimaOS/backend/migrations/003_portal_auth.sql
  • PrimaOS/frontend/src/api/portalApi.ts
  • PrimaOS/frontend/src/auth
Measured on PrimaOS main a556424 2026-09-12
Version 1

Purpose

Two passwordless doors into a clinic system: staff open a personal signed access link and get a signed bearer the SPA stores; patients prove control of a contact on file through a single-use magic link and get a 30-minute portal:basic session, then escalate to portal:results with a 6-digit SMS code before any physician-released result is readable. The invariant that makes it trustworthy is that scope only ever escalates through a fresh challenge row that is consumed once under FOR UPDATE, the challenge table is reachable only from the admin engine and never from a tenant-scoped request, and the reply to a magic-link request is the same sentence whether or not the contact exists.

This recipe fixes the portal_auth_challenge table and its two kinds, the six portal routes with their codes, the four token families and their salts, the two dependencies (get_portal_identity, require_portal_scope), the staff access-link redeem, and the contract-honest 501s that stand where passkeys will be.

When to use

Use it for a surface where the person authenticating has no account and must not get one (a patient, a relative, a supplier with a one-off document), where the credential is a link or a code delivered out of band, and where one class of data needs a second proof before it is shown. Trigger phrases: "magic link for patients", "SMS step-up", "no enumeration on the contact field", "portal:basic versus portal:results", "the staff access link", "passkeys are still 501".

Do not use it for the Labs magic link (B5, which mints a Labs session with grants underneath) or for any passkey ceremony (B7 on Labs, B22 on aurum; here the WebAuthn routes answer 501). D6 keeps the clinic domain out of this wave: this recipe documents the auth mechanics only, and names GET /portal/results as a read of physician-released results so that a customer building to it knows which route sits on the PHI side of the line. The founder decides whether B23 stays in identity-auth or moves with clinic; the frontend link is interim (frontmatter).

Stack assumptions

Measured at a556424 (2026-09-12): FastAPI with SQLAlchemy 2.0 on PostgreSQL 16 under RLS with FORCE (the app role is NOBYPASSRLS; wiki platform/primaos.md line 18), itsdangerous for every token (URLSafeSerializer untimed, URLSafeTimedSerializer timed), psycopg v3, pydantic settings with the PRIMAOS_ prefix (backend/app/core/config.py 18). The reference pins nothing: backend/pyproject.toml 5 to 16 carries floors only and there is no lock file (fastapi>=0.115, sqlalchemy>=2.0.30, psycopg[binary]>=3.2, pydantic>=2.7, pydantic-settings>=2.3, itsdangerous>=2.2, requires-python >=3.12); PostgreSQL 16 is postgres:16-alpine in backend/docker-compose.yml 6, on host port 5433 (13). What the author's environment resolved on 2026-09-12, and what every measurement below ran on: fastapi 0.139.0, sqlalchemy 2.0.51, psycopg 3.3.4, pydantic-settings 2.14.2, itsdangerous 2.2.0, Python 3.13.7. A new service pins. Foundations assumed: S4 for the app composition (create_app in backend/app/main.py), S5 for the vocabulary only (this service has no Labs session and no grants table; its dependencies are its own).

Environment names and fail behaviour (backend/app/core/config.py):

NameDefaultBehaviour
PRIMAOS_SECRET_KEYdev-only-change-me (25)signs every token family below; the default is not refused outside dev (Gotchas)
PRIMAOS_ENVdev (26)anything but dev or test forces debug off (39 to 43); staging on the deployed test instance (fly.toml 15)
PRIMAOS_DEBUGtrue (31)mounts GET /internal/dev/users, the demo router and CORS for the Vite ports (main.py 170 to 185); returns the _dev envelope with the link token and the OTP (portal.py 71 to 73); cannot be resurrected outside dev or test
PRIMAOS_DATABASE_URLthe RLS app roleserves every request (20 to 21)
PRIMAOS_DATABASE_ADMIN_URLthe superusermigrations, seed, and the pre-tenant auth lookups in portal.py (22 to 23; get_admin_engine, core/db.py 45)
PRIMAOS_PUBLIC_BASE_URLthe local originthe base for links sent out of band (46)
PRIMAOS_DOWNLOAD_URL_TTL_SECONDS300the timed download token, a sibling family not covered here (99)

Data model

-- backend/migrations/003_portal_auth.sql 16 to 38: one table for both patient ceremonies
portal_auth_challenge (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,     -- attribution and cleanup only
  patient_id   uuid NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
  kind         text NOT NULL CHECK (kind IN ('magic', 'otp')),
  code_hash    text,                        -- sha256(otp || ':' || secret_key) for otp; NULL for magic (portal.py 66 to 68)
  expires_at   timestamptz NOT NULL,        -- magic: created + 900 s; otp: created + 300 s
  consumed_at  timestamptz,                 -- set once; a consumed row is spent
  attempts     integer NOT NULL DEFAULT 0,  -- otp verify attempts; written to lock the row at 5; stays 0 at a556424 (Gotchas)
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX portal_auth_challenge_patient_kind_idx ON portal_auth_challenge (patient_id, kind, created_at DESC);
CREATE INDEX portal_auth_challenge_created_idx ON portal_auth_challenge (created_at);
REVOKE ALL ON portal_auth_challenge FROM primaos_app;    -- the admin engine's alone; no RLS policy on purpose (9 to 14, 36 to 38)

-- the rows the doors read (backend/migrations/001_init.sql 60 to 74; 002_r2_documents_ai_money.sql 146 to 147)
staff (
  id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id),
  role text NOT NULL CHECK (role IN ('physician','mpa','manager','operator')),
  display_name text NOT NULL, email text, credentials text,
  passkey_refs jsonb NOT NULL DEFAULT '[]',                     -- reserved; nothing writes it at a556424
  mfa_enforced boolean NOT NULL DEFAULT true,
  status text NOT NULL DEFAULT 'active' CHECK (status IN ('pending','active','suspended')),
  ...,                                                         -- specialties text[], bookable boolean, created_at, updated_at (001 67 to 68, 71 to 72)
  UNIQUE (tenant_id, email)
);
patients ( ..., contacts jsonb NOT NULL DEFAULT '{}', ... );   -- 001_init.sql 137 to 143; contacts->>'email' and ->>'phone' are the lookup keys

-- the tenancy policy every data read runs under (001_init.sql 340 to 357): one DO loop over a fixed table list
ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <t> FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON <t>
  USING      (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- <t> ranges over locations, staff, api_clients, appointment_types, schedules, schedule_exceptions, patients,
-- appointments, encounters, notes, documents, extractions, lab_results, messages, tasks, consents, calls,
-- audit_log, sync_events (344 to 347); portal_auth_challenge is not in the list, by design (003 36 to 38).
-- current_setting(..., true) is NULL when unset, so a request outside tenant_session reads zero rows (336 to 338).

-- the audit row the staff redeem writes (001_init.sql 308 to 320; core/audit.py 33 to 43)
audit_log (
  id bigserial PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  actor_type text NOT NULL CHECK (actor_type IN ('staff','patient','api','system')),
  actor_id uuid, action text NOT NULL, entity text NOT NULL, entity_id text, reason text,
  prev_hash text NOT NULL, hash text NOT NULL,                  -- sha256(prev_hash || canonical json), GENESIS is 64 zeros
  created_at timestamptz NOT NULL DEFAULT now()
);
-- append_audit(session, *, tenant_id, actor_type, actor_id, action, entity, entity_id=None, reason=None) -> AuditLog
-- runs inside the caller's tenant_session transaction under a per-tenant advisory lock (core/audit.py 45 to 49)

What is not stored: any password (the schema has no column for one and the docstring at core/security.py 3 to 4 calls it a hard rule); the magic link token (it is a signed {c: challenge_id}, the row is the use-once guard, migration header 4 to 5); the OTP (only its salted hash); any staff or portal session row (every session is a signed bearer with no server state); the access link (minted by a script and printed, core/security.py 157 to 162).

The token families, all signed with secret_key and told apart by salt (core/security.py):

FamilySerializer and saltPayloadLifetimeMinted by
staff session (X-Dev-Token)URLSafeSerializer, dev-token (35 to 42){t: tenant_id, s: staff_id, r: role}nonethe seed, GET /internal/dev/users in debug, and the access-link redeem (auth_link.py 75)
staff access linkURLSafeTimedSerializer, staff-access-link (164 to 188){t, s, r}30 days (ACCESS_LINK_TTL_SECONDS, 164)backend/scripts/provision_*.py, an operator shell
staff inviteURLSafeSerializer, staff-invite (153 to 154){t, s}nonePOST staff create in admin.py 240, returned once; nothing redeems it at a556424
portal magic linkURLSafeTimedSerializer, portal-magic (270 to 285){c: challenge_id}900 s (MAGIC_LINK_TTL_SECONDS, 229)POST /portal/auth/magic-link
portal sessionURLSafeTimedSerializer, portal-session (246 to 267){t, p: patient_id, sc: scope}1800 s (PORTAL_SESSION_TTL_SECONDS, 228)redeem and step-up verify

PortalIdentity (235 to 243) is tenant_id, patient_id, scope, with scopes as the space-split set; SCOPE_BASIC is portal:basic and SCOPE_RESULTS is portal:basic portal:results (231 to 232). DevIdentity (28 to 32) is tenant_id, staff_id, role. Request state written: request.state.identity (the staff identity or None, main.py 103 to 105) and request.state.request_id.

API surface

The portal router has no prefix and no router-level dependency (portal.py 54); each handler names its own. Error bodies are FastAPI {detail} in German, rendered as application/problem+json by the app's handlers (main.py _problem).

RouteAuth depTenancyCodesNotes
POST /portal/auth/magic-link {contact}nonepre-tenant: the lookup runs on the admin engine across tenants (106 to 114)202 {detail: "Falls dieser Kontakt hinterlegt ist, ist ein Link unterwegs."} always; 422 on a contact shorter than 3 or longer than 120unknown contact, a hit, and the silent cap (5 per patient per 15 min, MAGIC_MAX_PER_WINDOW) all answer the same sentence; in debug the body also carries _dev: {link_token}
POST /portal/auth/magic-link/redeem {link_token}nonethe row's tenant_id200 {access_token, token_type: "Bearer", expires_in: 1800, scope: "portal:basic"}; 401 Ungültiger oder abgelaufener Link. (bad signature or past 900 s); 401 Ungültiger Link. (no row); 410 Dieser Link wurde bereits verwendet.; 410 Dieser Link ist abgelaufen.SELECT ... FOR UPDATE then consumed_at = now() (163 to 181)
POST /portal/auth/step-up/requestget_portal_identity (any portal scope)the identity's tenant and patient202 {detail: "Ein Bestätigungscode wurde ..."} with _dev: {otp} in debug; 401 without a session; 429 Zu viele Anfragen... past 5 per patient per 15 min (OTP_MAX_PER_WINDOW)inserts an otp row with the salted hash and a 300 s expiry (188 to 216)
POST /portal/auth/step-up/verify {otp}get_portal_identityas above200 the session shape with scope: "portal:basic portal:results"; 422 unless six digits; 401 Kein aktiver Code... (no row); 401 Code abgelaufen... (consumed or expired); 401 Falscher Code. on every wrong code (243 to 247; the attempts + 1 at 244 is rolled back with the 401, so the row never counts a miss); 429 Zu viele Fehlversuche... is written at 239 to 240 for attempts >= 5 and is unreachable at a556424 (measured 2026-09-12, Gotchas)the newest otp row for the patient, FOR UPDATE (221 to 254); a service that fixed the increment answers 429 on the sixth call
GET /portal/meget_portal_identitytenant_session(identity.tenant_id), filtered by patient_id200 {patient: {name}, appointments: [...]}; 401; 404 Profil nicht gefunden.the tenant context comes from the token; RLS does the rest (259 to 287)
GET /portal/resultsrequire_portal_scope("portal:results")as above, released_to_portal = true only200 {results: [...]}; 401; 403 Für Befunde ist eine Bestätigung per SMS erforderlich (...) on a portal:basic sessionthe PHI read behind the step-up (290 to 320)
POST /internal/auth/access-link/redeem {token} (extra="forbid")nonethe link's tenant_id200 {staff_id, display_name, email, role, credentials, tenant_id, tenant_slug, tenant_name, token}; 401 Zugangslink ungültig oder abgelaufen... (signature or past 30 days); 401 Zugangslink ungültig. (no staff row, no tenant row, or status = 'suspended')audited as auth.access_link.redeem (auth_link.py 32 to 76); always mounted (main.py 189)
GET /internal/dev/usersnone; debug onlyevery tenant200 the seeded staff with a fresh token each, plus one synthetic manager identity for one seeded tenant (an MPA row re-minted with role: manager, dev.py 68 to 85); 404 outside debug when spa_dir is unset; 200 index.html outside debug on a same-origin SPA build, because the catch-all at main.py 224 to 231 serves it for any unregistered GET pathnot in the contract; the login picker (dev.py 35 to 86)
POST /internal/auth/webauthn/{register,login}/{options,verify}, POST /internal/auth/step-up, POST /internal/auth/logoutnonenone501 application/problem+json naming the operation id (core/security.py 324 to 378)mounted at main.py 188; the rest of the contract's unimplemented operations get generated 501s from stubs.py
every staff routeget_current_identity or require_roles(...) (core/security.py 59 to 91)the token's tenant_id through tenant_session401 Missing or invalid X-Dev-Token...; 403 Requires role: ...the middleware parses the header once per request (main.py 103 to 105)

Contracts the SPA consumes (frontend/src/api/portalApi.ts 115 to 207 and client.ts 632 to 636): the PortalSession schema from api/openapi.yaml 3941 to 3950 (access_token, token_type const Bearer, expires_in, scope enum of the two values); the 401, 410 and 429 bodies rendered inline rather than as a toast (silent, 57 and the comments at 123 and 148); the staff redeem's DevUser shape (auth/identity.ts 11 to 22).

Application logic

Patient magic link (portal.py 101 to 152):

  1. Trim the contact; lower-case it for the email comparison and keep it verbatim for the phone comparison (104 to 105).
  2. On the admin engine, SELECT id, tenant_id FROM patients WHERE contacts->>'email' = :email OR contacts->>'phone' = :phone LIMIT 1 (106 to 114). No row: log without the contact and answer the fixed sentence (115 to 118).
  3. Count this patient's magic rows in the last 15 minutes; at 5 or more, log a warning and answer the same sentence with no link (121 to 132).
  4. Insert a magic row with expires_at = now + 900 s, RETURNING id; sign {c: id} with the portal-magic salt; log the first 12 characters; answer the sentence, plus _dev.link_token in debug (134 to 152). Delivery goes through the comms queue, which does not transmit at a556424 (docstring 13 to 16).

Redeem (157 to 183): parse_magic_link_token (signature and max_age=900) or 401; on the admin engine SELECT tenant_id, patient_id, expires_at, consumed_at ... FOR UPDATE; no row 401; consumed 410; expired 410; UPDATE ... SET consumed_at = now(); answer make_portal_session(tenant, patient, "portal:basic") with expires_in: 1800.

Step-up request (188 to 216): the caller is a PortalIdentity of any scope; count otp rows in 15 minutes, 429 at 5; draw secrets.randbelow(1_000_000) as six digits; insert with code_hash = sha256(otp + ':' + secret_key) and a 300 s expiry; log without the code; answer with _dev.otp in debug.

Step-up verify (221 to 254): the newest otp row for the patient FOR UPDATE; none 401; consumed or expired 401; attempts >= 5 429 (239 to 240, evaluated before the compare); secrets.compare_digest against the salted hash; a miss runs UPDATE ... SET attempts = attempts + 1 and raises the 401 inside the same with get_admin_engine().begin() as conn: block (225, 242 to 247); a hit sets consumed_at and answers a new session with SCOPE_RESULTS. What the code does, not what it means to do: Engine.begin() commits on a clean exit and rolls back when an exception leaves the block (sqlalchemy 2.0.51, engine/base.py Engine.begin docstring), and the HTTPException is that exception, so the increment never commits. Measured 2026-09-12 at a556424 on a throwaway Postgres 16: six wrong codes against one row answer 401 Falscher Code. six times, the row reads attempts = 0 afterwards, and the right code then answers 200 on the same row. The 429 branch at 239 to 240 is dead and a 6-digit code with a 300 s window is guessable without bound (Gotchas; the fix it needs is there). A service built to this recipe commits the increment before it answers: wrong codes one to five each 401 and leave attempts at 1 to 5, the sixth call is the first 429. The portal:basic token the caller used keeps working until its own expiry; scopes escalate by issuing, never by mutating.

Reads (259 to 320): get_portal_identity parses the bearer (_portal_bearer, 288 to 292; parse_portal_session with max_age=1800), then tenant_session(identity.tenant_id) sets app.current_tenant_id transaction-locally (core/db.py 53 to 66; set_config 62 to 65) and the queries filter by identity.patient_id; results add released_to_portal.is_(True). require_portal_scope("portal:results") refuses a session whose scopes lack the value with 403 (306 to 321).

Staff access link (auth_link.py 32 to 76): parse_access_link_token (signature, max_age 30 days) or 401; inside tenant_session(identity.tenant_id), read the staff row and the tenant row; refuse when either is missing or status == 'suspended' (53 to 54); append_audit(db, tenant_id=..., actor_type="staff", actor_id=staff_id, action="auth.access_link.redeem", entity="staff", entity_id=str(staff_id)) (56 to 64); return the profile fields plus make_dev_token(tenant, staff, role), where both the role field and the minted token take identity.role from the link payload (70, 75), not staff.role from the row read at 44 (Gotchas); the SPA stores the token under primaos.identity in localStorage (auth/AuthContext.tsx 13, 38) and sends as X-Dev-Token on every internal call (api/client.ts 125, 197). The SPA's Login.tsx redeems ?access= on mount and navigates to the page the person came from (31 to 46); without the debug picker it shows the closed-access notice (23 to 25, 49 to 60).

Staff requests (main.py 85 to 125): the middleware parses X-Dev-Token once, sets request.state.identity, and after a successful GET on a route tagged openapi_extra={"x-data-class": "phi"} writes an audit row in its own tenant transaction; a failed audit write turns the response into a 500 (118 to 124). get_current_identity reads the state or re-parses the header, 401 otherwise (core/security.py 59 to 72); require_roles compares identity.role to the allowed set, 403 otherwise (75 to 91).

Invariants

  • The magic-link request answers one sentence with status 202 for an unknown contact, a known contact, and a capped one (portal.py 118, 132, 150; test_magic_link_unknown_contact_no_enumeration).
  • A magic link is consumed exactly once: the row is read FOR UPDATE and consumed_at is set in the same transaction; a second redeem is 410 (test_magic_link_is_single_use).
  • portal:results is reachable only through a fresh otp row consumed by step-up/verify; a portal:basic session on /portal/results is 403 (test_results_blocked_without_step_up, test_step_up_grants_results_scope).
  • The OTP is never stored or logged in clear; the hash is salted with secret_key so a database read alone cannot verify guesses (66 to 68); the comparison is constant-time (242).
  • portal_auth_challenge is read and written on the admin engine only; the app role has no grant on it and no tenant-scoped path touches it (migration 9 to 14, 36 to 38; every get_admin_engine() call in portal.py is inside the four auth handlers).
  • Every data read runs under tenant_session(identity.tenant_id) with the tenant taken from the token, and every query also filters by identity.patient_id (272, 300).
  • Scopes escalate by minting a new token; no route widens an existing one (_session_response, 90 to 96, is the only writer).
  • Staff tokens carry the role, and require_roles is the role check for every staff route except the external-site proxy, where routers/website.py 117 to 121 repeats the same comparison inline against _EXTERNAL_ROLES (113); both read only identity.role from the token, and grep -rn 'identity.role' app/routers app/core at a556424 finds no third (the two hits in auth_link.py 70 and 75 mint, they do not check). The SPA's RequireRole is UX, not security (auth/RequireRole.tsx 6 to 10). A new service writes the proxy's check as Depends(require_roles(*_EXTERNAL_ROLES)).
  • The access-link redeem is audited and refuses a suspended staff row (auth_link.py 53 to 64).
  • The debug conveniences (/internal/dev/users, the _dev envelope, CORS) exist only when env is dev or test, and PRIMAOS_DEBUG=true cannot bring them back elsewhere (config.py 39 to 43; dev.py 37 to 38).
  • Every contract operation that is not implemented answers 501 application/problem+json with its operation id rather than 404 (stubs.py 1 to 7).

Frontend

identity-auth/ui/auth-screens              interim link (frontmatter): the identity surface whose login, magic-link
                                            and passkey screens are the nearest existing pattern; renders none of
                                            these routes
clinic/ui/clinic-patient-portal-ui (F102)   not a file yet (D6): frontend/src/pages/Portal.tsx, the magic-link
                                            landing (?token= redeemed once, then dropped from the URL, 79 to 90),
                                            the demo link in debug, the step-up code form, the results list gated
                                            on hasResultsScope; session in sessionStorage under primaos.portal_session
                                            (portalApi.ts 17 to 43)
clinic/ui/clinic-reception-ui (F99)         not a file yet (D6): frontend/src/pages/Login.tsx (the ?access= redeem,
                                            the closed-access notice, the debug picker), auth/AuthContext.tsx
                                            (primaos.identity in localStorage), auth/RequireRole.tsx (the 403 screen)

Minimum admin and debug surface: GET /internal/dev/users in a dev build (every seeded staff with a token); the _dev envelope on the two 202s in a dev build; the audit_log rows auth.access_link.redeem; the log lines portal magic-link requested for unknown contact, portal magic-link rate-cap hit for a patient, portal magic-link issued (delivery mocked), portal step-up OTP issued (SMS delivery mocked); the X-Request-ID header on every response (main.py 108).

Security & tenancy

Auth dependency: get_portal_identity and require_portal_scope for patients; get_current_identity and require_roles for staff; no dependency on the four auth-issuing routes. Workspace derivation: the tenant is inside every token (t), set on the connection by tenant_session, and enforced by RLS with FORCE; the pre-tenant lookups (contact to patient, challenge rows) run on the admin engine by design because the patient has not proven a tenant yet (portal.py 18 to 21). Fail direction, per seam: signature, expiry and scope closed; the enumeration guard closed (one sentence); the magic-link cap closed silently; the OTP lockout written closed (five wrong codes, then 429) and measured open at a556424 (every wrong code is 401 and attempts stays 0, because the increment is rolled back with the 401; Gotchas), so the only bound on guessing a live code is its 300 s window; the audit write on a tagged staff read closed (a failed audit is a 500, main.py 118 to 124); the comms delivery is a queue that does not transmit, so the ceremony is demoable only in debug.

Credentials at rest: none beyond secret_key in the environment and the OTP hash in the challenge row; no session table, no password column, no token stored server-side. In the browser: the portal session in sessionStorage (gone with the tab, no "remember me", portalApi.ts 17 to 19); the staff token in localStorage (auth/AuthContext.tsx 13).

Rate limits, all counted in the challenge table per patient: 5 magic links per 15 min (silent), 5 OTP requests per 15 min (429), and 5 wrong codes per OTP row as written (OTP_MAX_ATTEMPTS, 57) but none as measured at a556424 (the counter never commits; Gotchas). None per IP address; none on the redeem routes; none on the staff redeem. On the reference, then, one 6-digit code is open to 1,000,000 guesses for 300 s from any address; a service built to this recipe has the per-row lockout and a per-IP limit on verify.

Attacker-controlled inputs: contact (bounded 3 to 120, used only as a bound query parameter); link_token and token (signature first, then max_age, then the row); otp (regex ^[0-9]{6}$ before anything else, then constant-time compare); the Authorization header (prefix-checked, then the timed serializer); X-Dev-Token (the untimed serializer); the JSON body of the access-link redeem (extra="forbid").

Tenancy: a patient token is bound to one tenant and one patient; a staff token to one tenant, one staff row and one role; cross-tenant ids are unreachable because RLS filters them before the application sees them (S6's 404-not-403 rule holds by construction: portal_me answers 404 when the row is not visible, 264 to 265).

Tests

  • backend/tests/test_portal_auth.py (10 cases, pytest backend/tests/test_portal_auth.py with the compose Postgres on localhost:5433, PRIMAOS_SECRET_KEY=test-secret set by conftest.py 32): test_magic_link_unknown_contact_no_enumeration (53), test_magic_link_known_contact_issues_link (62), test_magic_link_is_single_use (69, the second redeem is 410), test_magic_link_garbage_token_401 (77), test_portal_me_requires_auth (84), test_portal_me_returns_own_profile (88), test_results_blocked_without_step_up (97, 403), test_step_up_wrong_otp_rejected (105, 401), test_step_up_grants_results_scope (112, the scope string and a 200 on results), test_step_up_verify_requires_basic_session (125). The _clear_challenges fixture truncates the table before each case because the per-patient cap is real (20 to 30). The harness drops and recreates a database named primaos_test, applies the plain-SQL migrations and runs the seed (conftest.py 47 onward).
  • Run 2026-09-12 at a556424 on a throwaway postgres:16-alpine with PRIMAOS_TEST_DB=primaos_b23_fix PRIMAOS_TEST_DB_HOST=127.0.0.1 PRIMAOS_TEST_DB_PORT=5642 .venv/bin/python -m pytest tests/test_portal_auth.py -q -o addopts="" (pyproject.toml 33 sets addopts = "-q", which hides the summary line unless cleared): 10 passed in 2.15s. The overrides at conftest.py 22 to 24 exist for exactly this: the default port 5433 collides on a machine that runs another Postgres there, and the harness drops and recreates its database on whatever it reaches (57 to 60). The last run before that was the wiki's "85 pytest green" on 2026-07-11 (platform/primaos.md 18), which predates the staff access link (86934e0, 2026-08-12).
  • No test covers the OTP lockout: test_step_up_wrong_otp_rejected (105 to 110) sends one wrong code and asserts one 401, which passes whether or not attempts was committed. That is why the dead branch at portal.py 239 to 240 went unnoticed (Gotchas). A service built to this recipe adds the case that reads attempts back after a miss and asserts 429 on the sixth call.
  • backend/tests/test_agenda_security_coverage.py and test_tenancy_isolation.py: the staff-side 401 and 403 and the cross-tenant CI test (wiki, S6 finding).
  • Known-answer vectors: the magic-link sentence Falls dieser Kontakt hinterlegt ist, ist ein Link unterwegs.; the two scope strings; expires_in 1800; the 403 text on results; the itsdangerous token shape <base64url payload>.<timestamp>.<signature> for the timed families and <payload>.<signature> for the untimed ones, with one twist: itsdangerous zlib-compresses a payload when that comes out shorter and marks it with a leading ., so a token carries one more dot than its family suggests. Measured 2026-09-12 with core/security.py at a556424 and itsdangerous 2.2.0: make_dev_token(...) has 2 dots and starts with ., make_access_link_token(...) 3 and starts with ., make_portal_session(...) 3 and starts with ., make_magic_link_token(...) 2 with no leading dot (its {c: uuid} payload is not compressed). A verifier that splits on . and counts parts per family is wrong; the salt, not the dot count, decides which parser accepts a token, and that held in the same run: the access link through parse_dev_token and the dev token through parse_access_link_token both return None.
  • Hermetic versus live: the pytest suite is hermetic against the compose Postgres; nothing transmits a link or a code; the deployed test instance carries synthetic data only (wiki line 23). Playwright: frontend/e2e/public.spec.ts covers the public booking pages, and its portal mock (W5) block (94 to 122) walks /portal as a frontend mock: it fills a contact, clicks the demo link, requests the OTP, enters the four-digit code 1234 and opens /portal?token=demo-neu. The spec header (11) still calls the portal a mock that always runs; Portal.tsx (30 to 46) has called the real backend since 2ed301e (2026-07-11; the spec was last touched in 7bf9561, 2026-07-10) and StepUpVerify refuses anything but six digits (portal.py 87), so that block is stale against the reference (Gotchas). No spec walks the access link at a556424.

Deploy & ops

Config keys per app (the deployed test instance reads fly.toml 14 to 16 and its platform store): PRIMAOS_ENV=staging (closes the picker and the _dev envelope), PRIMAOS_SECRET_KEY from the store, PRIMAOS_PUBLIC_BASE_URL, the two database URLs, PRIMAOS_SPA_DIR when the SPA is served same-origin (config.py 54). The production target is the Swiss estate (S13); the deployed test instance is not Swiss-resident and carries synthetic data only (wiki line 23).

Migrations order: 001_init.sql (staff, patients, tenants), 002_r2_documents_ai_money.sql (staff.status, lab_results.released_by), then 003_portal_auth.sql; plain SQL applied by backend/scripts/db_init.py on the admin URL. 003 ends with REVOKE ALL ... FROM primaos_app, so the app role must exist before it runs.

Operator runbook for a staff member on a closed instance: mint an access link with backend/scripts/provision_*.py (idempotent; re-running re-mints; the link prints once and is valid 30 days; core/security.py 157 to 162), send it out of band, and the person opens /login?access=<token>. Reopening the debug picker is PRIMAOS_ENV=test plus a redeploy (wiki line 127). Rotating PRIMAOS_SECRET_KEY invalidates every token of every family at once, which is the only revoke for a staff token (Gotchas).

Scheduler jobs: none; expired challenge rows are never deleted (no job reads portal_auth_challenge_created_idx for cleanup at a556424). Argus checks: GET /healthz 200; POST /portal/auth/magic-link with an unknown contact answers 202 and the sentence without _dev; POST /internal/auth/webauthn/login/options answers 501 with application/problem+json. Rollback: the image; the challenge table is additive and an older build ignores it.

Gotchas

  • 2026-09-12 (measured at a556424, portal.py 225 and 239 to 247; D8, a verified defect recorded here and not fixed in this wave): the OTP lockout does not exist. portal_step_up_verify opens with get_admin_engine().begin() as conn: (225), and on a wrong code runs UPDATE portal_auth_challenge SET attempts = attempts + 1 (243 to 246) and raises the 401 Falscher Code. (247) inside that block; Engine.begin() rolls the transaction back when an exception leaves it (sqlalchemy 2.0.51, engine/base.py Engine.begin docstring: "If an error is raised, the Transaction is rolled back"), so the increment never commits, attempts never leaves 0, and the attempts >= OTP_MAX_ATTEMPTS 429 at 239 to 240 is unreachable. Reproduced on 2026-09-12 against a throwaway postgres:16-alpine after 10 passed: six POST /portal/auth/step-up/verify calls with a wrong code all answered 401 Falscher Code., the row then read (attempts, consumed_at IS NULL, length(code_hash)) = (0, true, 64), and the right code on that same row answered 200 with portal:basic portal:results. A 6-digit code with a 300 s window and no per-IP limit is therefore guessable without bound, and test_step_up_wrong_otp_rejected (tests/test_portal_auth.py 105 to 110) asserts one 401 only, so nothing catches it. Fix it needs: commit the increment before answering (run the UPDATE in its own begin() block, or record the miss inside the block and build the 401 after the with exits), plus a test that reads attempts back after one wrong code and asserts 429 on the sixth call, and a per-IP limit on verify while at it. Until then every claim in this recipe that says "five wrong codes, then 429" describes a service built to the recipe, not the reference.
  • 2026-09-12 (measured at a556424, core/config.py 25 and 39 to 43; D8, a verified defect recorded here and not fixed in this wave): secret_key defaults to dev-only-change-me and the validator only forces debug off outside dev and test; it does not refuse the default. Every token family (staff session, access link, invite, magic link, portal session, download, manage, embed, slot key) is signed with that value, so a deployment that forgets PRIMAOS_SECRET_KEY accepts forged staff tokens and forged portal sessions. The deployed test instance sets it from the platform store (fly.toml 16); nothing in code guarantees the next one will. Fix it needs: a model_validator that raises when env is not dev or test and secret_key is the default or shorter than 32 bytes.
  • 2026-09-12 (measured at a556424, core/security.py 35 to 42, 45 to 56; main.py 103 to 105; D8): the staff token minted by the access-link redeem is an untimed URLSafeSerializer payload with no expiry and no server state, and neither parse_dev_token nor the middleware reads staff.status. A staff member suspended after redeeming keeps working until PRIMAOS_SECRET_KEY is rotated, which signs everyone out. Fix it needs: a timed serializer with a max_age for the staff session, and a status = 'active' check in get_current_identity (one indexed read per request, or a short cache).
  • 2026-09-12 (measured at a556424, core/security.py 164 to 188, auth_link.py 32 to 76): the access link is a reusable bearer URL for 30 days: no challenge row, no use-once guard, redeemable any number of times, and the only revoke is the same key rotation. It "replaces an invite email flow for now" (159 to 162). A customer building to this recipe treats it as the patient magic link is treated: a row, consumed once, minutes not days.
  • 2026-09-12 (measured at a556424, portal.py 106 to 114; D8): the contact lookup is LIMIT 1 across every tenant with no tenant scoping and no ordering, so a patient whose contact is on file at two practices only ever receives a link for whichever row Postgres returns first, and the other practice's portal is unreachable for them. Fix it needs: either a tenant hint in the request (the practice's public base URL is known to the page) or one challenge per matching row.
  • 2026-09-12 (measured at a556424, main.py 85 to 125, portal.py 290 to 320; D8): the PHI read audit is keyed on X-Dev-Token and on openapi_extra x-data-class: phi; the portal routes carry a Bearer and no tag, so a patient reading released results leaves no audit row, while a staff member reading the same result does. Fix it needs: an append_audit inside portal_results under the same tenant_session, actor_type="patient".
  • 2026-09-12 (measured at a556424, portal.py 115 to 118 and 134 to 152): the inline comment at 116 says the unknown-contact path behaves identically with "no timing/response tell" (the module docstring, 5 to 7, promises only an identical response); the known path does an insert and a signature the unknown path does not. The sentence and the status are identical; the latency is not.
  • 2026-09-12 (measured at a556424, auth_link.py 44, 70 and 75; D8): the access-link redeem reads staff.role from the row (44) and then ignores it: the role field and the re-minted staff token both take identity.role from the link payload (70, 75). A role change after the link was minted never reaches the session, and with the 30-day reusable link above the stale role stays redeemable for as long as the link does. Fix it needs: make_dev_token(identity.tenant_id, staff.id, staff.role) and "role": staff.role.
  • 2026-09-12 (measured at a556424): staff.passkey_refs and mfa_enforced exist in the schema (001 69 to 70) and the six 501 stubs (four WebAuthn, step-up, logout; core/security.py 324 to 378) stand where the ceremony would be; nothing writes either column. The one read is passkey_count in the admin staff serializer (admin.py 182, len(s.passkey_refs or [])), always 0 at a556424; nothing reads mfa_enforced. The inventory row's "no per-patient authorization on the patient surface yet" is not what the code does at a556424: /portal/me and /portal/results both filter by identity.patient_id (272, 300); the code wins.
  • 2026-09-12 (measured at a556424, frontend/src/api/portalApi.ts 6 and core/security.py 246 to 251): the client comment calls the portal session a JWT; it is an itsdangerous signed payload, not a JWS. A verifier written against the JWT shape will not parse it.
  • 2026-09-12 (measured at a556424, frontend/e2e/public.spec.ts 11 and 94 to 122; D8): the Playwright portal mock (W5) block still walks /portal as a client-side mock (a four-digit code 1234, a demo-neu token) and the header says it "always runs"; Portal.tsx has called the real backend since 2ed301e (2026-07-11) and the verify model refuses anything but six digits (portal.py 87). Against a real stack the block fails, so it is no gate for this recipe. Fix it needs: rewrite the block against a dev build using the _dev envelope (the link token from the 202, the OTP from the step-up 202) or delete it.
  • 2026-09-12 (this repo at 20a0fef, modules/manifest.json edges; scripts/check.mjs 173 to 179): the interim frontend_modules link to identity-auth/ui/auth-screens puts a one-sided edge in the manifest (auth-screens to this recipe) that auth-screens' own backend_modules does not return (B1 to B8 and B18; no B23), and its demo renders Labs' login, not /portal or /login?access=. A reader of the manifest, or a customer loading B23 through primacode, sees F1's demo as the rendering of a surface it does not render. Founder-gated under D6: either two status: unwritten stubs clinic/ui/clinic-patient-portal-ui and clinic/ui/clinic-reception-ui with backend_modules: [identity-auth/clinic-staff-and-patient-auth] (the gate accepts an unwritten stub's headings-only body), with the frontmatter rows swapped, or B23 moves to modules/clinic/ with them. Until decided, the edge is documented here, not hidden.
  • 2026-07-23, committed to main in 86934e0 on 2026-08-12 (fly.toml 11, "CLOSED instance (2026-07-23)"; wiki platform/primaos.md, the section heading at 122 and its "Public demo CLOSED" bullet at 127, 37c6543): the public demo was closed (PRIMAOS_ENV=staging), which unmounts the picker, and the access-link redeem was added as the only staff door; the Login.tsx closed-access notice dates from the same change.
  • 2026-07-11 (61c6ec3): the portal ceremonies replaced 501 stubs; migration 003 and test_portal_auth.py landed together; the wiki's "85 pytest green" is from that day.

Reference implementation

Paths relative to the PrimaOS repo root; branch main at a556424 (2026-08-20); the portal ceremonies from 61c6ec3 (2026-07-11), the staff access link from 86934e0 (2026-08-12).

  • backend/app/routers/portal.py (320 lines): docstring (1 to 22), constants (56 to 59), _hash_otp (66 to 68), _dev_envelope (71 to 73), the request models (78 to 87), _session_response (90 to 96), portal_magic_link_request (101 to 152), portal_magic_link_redeem (157 to 183), portal_step_up_request (188 to 216), portal_step_up_verify (221 to 254), portal_me (259 to 287), portal_results (290 to 320).
  • backend/app/routers/auth_link.py (76 lines): the model (26 to 29), redeem_access_link (32 to 76).
  • backend/app/core/security.py (378 lines): DevIdentity (28 to 32), the dev token (41 to 56), get_current_identity (59 to 72), require_roles (75 to 91), the manage, embed and download tokens (96 to 148), make_invite_token (153 to 154), the access link (164 to 188), the slot key (196 to 219), the portal constants and PortalIdentity (228 to 243), make_portal_session and parse_portal_session (246 to 267), the magic-link token (270 to 285), _portal_bearer (288 to 292), get_portal_identity (295 to 303), require_portal_scope (306 to 321), the six 501 stubs (324 to 378).
  • backend/app/routers/stubs.py (124 lines): the generator (1 to 55) and mount_contract_stubs; backend/app/routers/dev.py (the picker, 1 to 16 and 35 to 86; the synthetic manager identity 68 to 85); backend/app/routers/admin.py 182 (the one passkey_refs read); backend/app/routers/website.py 113 to 121 (_EXTERNAL_ROLES and the inline role check, the one role comparison outside require_roles).
  • backend/pyproject.toml 5 to 16 (the floors; no lock file), backend/docker-compose.yml 6 and 13 (postgres:16-alpine on host port 5433).
  • backend/app/main.py: TenantAuditMiddleware (85 to 125), _write_audit (128), the debug block (170 to 185), the router order (187 to 209).
  • backend/app/core/config.py (the settings under Stack assumptions), backend/app/core/db.py (get_admin_engine 45, tenant_session 53 to 66), backend/app/core/audit.py (81 lines; append_audit 33 to 43, the advisory lock 45 to 49).
  • backend/migrations/003_portal_auth.sql (38 lines), 001_init.sql (staff 60 to 74, patients 137 to 143), 002_r2_documents_ai_money.sql (146 to 147).
  • backend/tests/test_portal_auth.py (the ten cases), backend/tests/conftest.py (the harness: overrides 22 to 24, the key 32, the database fixture 47 onward); frontend/e2e/public.spec.ts (11, 94 to 122; the stale portal mock).
  • frontend/src/api/portalApi.ts (207 lines), frontend/src/api/client.ts (125, 197, 632 to 636), frontend/src/auth/AuthContext.tsx, frontend/src/auth/identity.ts, frontend/src/auth/RequireRole.tsx, frontend/src/pages/Login.tsx (20 to 60), frontend/src/pages/Portal.tsx (32 to 44, 62 to 90), frontend/src/App.tsx (60).
  • api/openapi.yaml: PortalSession (3941 to 3950), the WebAuthn operations (122 to 252), the staff session cookie note (3698).
  • fly.toml (11, 14 to 17). Wiki platform/primaos.md (18, 23, 25, 122 to 127 at 37c6543).

Deviations of the reference from this recipe, dated 2026-09-12: the OTP lockout dead because the attempts increment is rolled back with the 401 (portal.py 239 to 247), so wrong codes are unbounded and no test notices; the unrefused default signing key; the unexpiring, unrevocable staff token; the reusable 30-day access link; the cross-tenant LIMIT 1 lookup; no audit row on a patient's results read; the role re-minted from the link rather than the staff row; passkeys 501; the JWT wording in the client; a Playwright portal block that walks a mock the app no longer has.

Acceptance checks

Run from PrimaOS/backend at the commit in measured_on with the compose Postgres on localhost:5433 (or a throwaway one named through PRIMAOS_TEST_DB, PRIMAOS_TEST_DB_HOST and PRIMAOS_TEST_DB_PORT, which is how the 2026-09-12 run went), or from a new service built to this recipe. Expected observations are exact; where the reference and a service built to the recipe differ, both readings are given.

  1. python -m pytest tests/test_portal_auth.py -q -o addopts="" prints 10 passed (skipped with the message naming the port when Postgres is not reachable). Measured 2026-09-12 at a556424: 10 passed in 2.15s.
  2. grep -n "get_admin_engine()" app/routers/portal.py | wc -l prints 4, and each is inside one of the four /portal/auth/* handlers; grep -c "get_admin_engine" app/routers/*.py shows 0 for every other router.
  3. psql "$PRIMAOS_DATABASE_ADMIN_URL" -tAc "SELECT has_table_privilege('primaos_app','portal_auth_challenge','SELECT')" prints f; SELECT relrowsecurity FROM pg_class WHERE relname = 'portal_auth_challenge' prints f.
  4. With the API running in a dev build: curl -s -i -X POST http://127.0.0.1:8000/portal/auth/magic-link -H 'content-type: application/json' -d '{"contact":"nobody@example.invalid"}' shows 202, the sentence Falls dieser Kontakt hinterlegt ist, ist ein Link unterwegs. and no _dev; the same with a seeded contact shows the same status and sentence plus _dev.link_token; the sixth request for the same contact within 15 minutes shows the sentence without _dev.
  5. POST /portal/auth/magic-link/redeem with that link answers 200 with "scope":"portal:basic", "token_type":"Bearer", "expires_in":1800; the same call again answers 410 with Dieser Link wurde bereits verwendet.; with {"link_token":"garbage"} it answers 401.
  6. GET /portal/results with the basic bearer answers 403 and Für Befunde ist eine Bestätigung per SMS erforderlich; GET /portal/me answers 200 with patient.name and an appointments list; without a bearer both answer 401.
  7. POST /portal/auth/step-up/request with the basic bearer answers 202 with _dev.otp (six digits) in a dev build. Then one sequence of six POST /portal/auth/step-up/verify calls with {"otp":"000000"} (when that is not the code) against that one code. At a556424 all six answer 401 Falscher Code., there is no 429, and the right code sent seventh on the same row answers 200 with "scope":"portal:basic portal:results" (measured 2026-09-12, the first Gotcha). On a service that fixed the increment, calls one to five answer 401 Falscher Code. (the fifth leaves attempts = 5), call six answers 429 Zu viele Fehlversuche. Bitte fordern Sie einen neuen Code an., and only a fresh request plus the right code answer 200 with that scope. In both cases GET /portal/results with the results bearer is 200.
  8. curl -s -i -X POST http://127.0.0.1:8000/internal/auth/webauthn/login/options shows 501, content-type: application/problem+json and authWebauthnLoginOptions in detail; POST /internal/auth/logout likewise names authLogout.
  9. PRIMAOS_ENV=staging (a rebuilt process): GET /internal/dev/users answers 404 when PRIMAOS_SPA_DIR is unset, and 200 with content-type: text/html (the SPA's index.html, main.py 224 to 231) when a built SPA is mounted the way fly.toml 17 does, so an Argus check here asserts on the body carrying no items rather than on the status; the magic-link 202 carries no _dev; POST /internal/auth/access-link/redeem with a token from make_access_link_token answers 200 with a token field, and the same call with the JSON key tokn answers 422 (extra="forbid").
  10. python -c "from app.core.config import get_settings; print(get_settings().secret_key == 'dev-only-change-me')" with PRIMAOS_SECRET_KEY unset, PRIMAOS_ENV=staging and no .env in the working directory that sets the key (pydantic-settings reads .env from the cwd, config.py 18) prints True at a556424 (the first Gotcha); on a service that fixed it the import raises.
  11. After step 7, SELECT attempts, consumed_at IS NULL, length(code_hash) FROM portal_auth_challenge WHERE kind = 'otp' ORDER BY created_at DESC LIMIT 2. At a556424, read between the sixth wrong call and the right one, the newest row is (0, true, 64): six misses and nothing counted (measured 2026-09-12 on a table truncated before step 7, where it was the only row); after the right code the same row reads (0, false, 64). On a service that fixed the increment it returns two rows: (0, false, 64) for the fresh row the right code consumed, then (5, true, 64) for the locked row that took the six wrong guesses (the sixth was refused before the counter). SELECT code_hash IS NULL, consumed_at IS NOT NULL FROM portal_auth_challenge WHERE kind = 'magic' ORDER BY created_at DESC LIMIT 1 returns (true, true) when the link redeemed in step 5 was the last one issued in step 4.
  12. In the SPA: opening /portal?token=<link> redeems once, removes token from the URL, and a reload does not call /redeem again; closing the tab drops the session (sessionStorage); opening /login?access=<link> signs the staff member in and lands on /agenda.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.