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 |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
PRIMAOS_SECRET_KEY | dev-only-change-me (25) | signs every token family below; the default is not refused outside dev (Gotchas) |
PRIMAOS_ENV | dev (26) | anything but dev or test forces debug off (39 to 43); staging on the deployed test instance (fly.toml 15) |
PRIMAOS_DEBUG | true (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_URL | the RLS app role | serves every request (20 to 21) |
PRIMAOS_DATABASE_ADMIN_URL | the superuser | migrations, seed, and the pre-tenant auth lookups in portal.py (22 to 23; get_admin_engine, core/db.py 45) |
PRIMAOS_PUBLIC_BASE_URL | the local origin | the base for links sent out of band (46) |
PRIMAOS_DOWNLOAD_URL_TTL_SECONDS | 300 | the 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):
| Family | Serializer and salt | Payload | Lifetime | Minted by |
|---|---|---|---|---|
staff session (X-Dev-Token) | URLSafeSerializer, dev-token (35 to 42) | {t: tenant_id, s: staff_id, r: role} | none | the seed, GET /internal/dev/users in debug, and the access-link redeem (auth_link.py 75) |
| staff access link | URLSafeTimedSerializer, staff-access-link (164 to 188) | {t, s, r} | 30 days (ACCESS_LINK_TTL_SECONDS, 164) | backend/scripts/provision_*.py, an operator shell |
| staff invite | URLSafeSerializer, staff-invite (153 to 154) | {t, s} | none | POST staff create in admin.py 240, returned once; nothing redeems it at a556424 |
| portal magic link | URLSafeTimedSerializer, portal-magic (270 to 285) | {c: challenge_id} | 900 s (MAGIC_LINK_TTL_SECONDS, 229) | POST /portal/auth/magic-link |
| portal session | URLSafeTimedSerializer, 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).
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /portal/auth/magic-link {contact} | none | pre-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 120 | unknown 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} | none | the row's tenant_id | 200 {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/request | get_portal_identity (any portal scope) | the identity's tenant and patient | 202 {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_identity | as above | 200 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/me | get_portal_identity | tenant_session(identity.tenant_id), filtered by patient_id | 200 {patient: {name}, appointments: [...]}; 401; 404 Profil nicht gefunden. | the tenant context comes from the token; RLS does the rest (259 to 287) |
GET /portal/results | require_portal_scope("portal:results") | as above, released_to_portal = true only | 200 {results: [...]}; 401; 403 Für Befunde ist eine Bestätigung per SMS erforderlich (...) on a portal:basic session | the PHI read behind the step-up (290 to 320) |
POST /internal/auth/access-link/redeem {token} (extra="forbid") | none | the link's tenant_id | 200 {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/users | none; debug only | every tenant | 200 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 path | not 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/logout | none | none | 501 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 route | get_current_identity or require_roles(...) (core/security.py 59 to 91) | the token's tenant_id through tenant_session | 401 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):
- Trim the contact; lower-case it for the email comparison and keep it verbatim for the phone comparison (104 to 105).
- 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). - Count this patient's
magicrows in the last 15 minutes; at 5 or more, log a warning and answer the same sentence with no link (121 to 132). - Insert a
magicrow withexpires_at = now + 900 s,RETURNING id; sign{c: id}with theportal-magicsalt; log the first 12 characters; answer the sentence, plus_dev.link_tokenin 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.py118, 132, 150;test_magic_link_unknown_contact_no_enumeration). - A magic link is consumed exactly once: the row is read
FOR UPDATEandconsumed_atis set in the same transaction; a second redeem is 410 (test_magic_link_is_single_use). portal:resultsis reachable only through a freshotprow consumed bystep-up/verify; aportal:basicsession on/portal/resultsis 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_keyso a database read alone cannot verify guesses (66 to 68); the comparison is constant-time (242). portal_auth_challengeis 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; everyget_admin_engine()call inportal.pyis 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 byidentity.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_rolesis the role check for every staff route except the external-site proxy, whererouters/website.py117 to 121 repeats the same comparison inline against_EXTERNAL_ROLES(113); both read onlyidentity.rolefrom the token, andgrep -rn 'identity.role' app/routers app/coreat a556424 finds no third (the two hits inauth_link.py70 and 75 mint, they do not check). The SPA'sRequireRoleis UX, not security (auth/RequireRole.tsx6 to 10). A new service writes the proxy's check asDepends(require_roles(*_EXTERNAL_ROLES)). - The access-link redeem is audited and refuses a suspended staff row (
auth_link.py53 to 64). - The debug conveniences (
/internal/dev/users, the_devenvelope, CORS) exist only whenenvisdevortest, andPRIMAOS_DEBUG=truecannot bring them back elsewhere (config.py39 to 43;dev.py37 to 38). - Every contract operation that is not implemented answers 501
application/problem+jsonwith its operation id rather than 404 (stubs.py1 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.pywith the compose Postgres onlocalhost:5433,PRIMAOS_SECRET_KEY=test-secretset byconftest.py32):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_challengesfixture truncates the table before each case because the per-patient cap is real (20 to 30). The harness drops and recreates a database namedprimaos_test, applies the plain-SQL migrations and runs the seed (conftest.py47 onward).- Run 2026-09-12 at a556424 on a throwaway
postgres:16-alpinewithPRIMAOS_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.toml33 setsaddopts = "-q", which hides the summary line unless cleared):10 passed in 2.15s. The overrides atconftest.py22 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.md18), 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 notattemptswas committed. That is why the dead branch atportal.py239 to 240 went unnoticed (Gotchas). A service built to this recipe adds the case that readsattemptsback after a miss and asserts 429 on the sixth call. backend/tests/test_agenda_security_coverage.pyandtest_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_in1800; 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 withcore/security.pyat 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 throughparse_dev_tokenand the dev token throughparse_access_link_tokenboth returnNone. - 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.tscovers the public booking pages, and itsportal mock (W5)block (94 to 122) walks/portalas a frontend mock: it fills a contact, clicks the demo link, requests the OTP, enters the four-digit code1234and 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) andStepUpVerifyrefuses anything but six digits (portal.py87), 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.py225 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_verifyopenswith get_admin_engine().begin() as conn:(225), and on a wrong code runsUPDATE portal_auth_challenge SET attempts = attempts + 1(243 to 246) and raises the 401Falscher Code.(247) inside that block;Engine.begin()rolls the transaction back when an exception leaves it (sqlalchemy 2.0.51,engine/base.pyEngine.begindocstring: "If an error is raised, the Transaction is rolled back"), so the increment never commits,attemptsnever leaves 0, and theattempts >= OTP_MAX_ATTEMPTS429 at 239 to 240 is unreachable. Reproduced on 2026-09-12 against a throwawaypostgres:16-alpineafter10 passed: sixPOST /portal/auth/step-up/verifycalls with a wrong code all answered 401Falscher 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 withportal:basic portal:results. A 6-digit code with a 300 s window and no per-IP limit is therefore guessable without bound, andtest_step_up_wrong_otp_rejected(tests/test_portal_auth.py105 to 110) asserts one 401 only, so nothing catches it. Fix it needs: commit the increment before answering (run theUPDATEin its ownbegin()block, or record the miss inside the block and build the 401 after thewithexits), plus a test that readsattemptsback after one wrong code and asserts 429 on the sixth call, and a per-IP limit onverifywhile 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.py25 and 39 to 43; D8, a verified defect recorded here and not fixed in this wave):secret_keydefaults todev-only-change-meand the validator only forcesdebugoff 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 forgetsPRIMAOS_SECRET_KEYaccepts forged staff tokens and forged portal sessions. The deployed test instance sets it from the platform store (fly.toml16); nothing in code guarantees the next one will. Fix it needs: amodel_validatorthat raises whenenvis notdevortestandsecret_keyis the default or shorter than 32 bytes. - 2026-09-12 (measured at a556424,
core/security.py35 to 42, 45 to 56;main.py103 to 105; D8): the staff token minted by the access-link redeem is an untimedURLSafeSerializerpayload with no expiry and no server state, and neitherparse_dev_tokennor the middleware readsstaff.status. A staff member suspended after redeeming keeps working untilPRIMAOS_SECRET_KEYis rotated, which signs everyone out. Fix it needs: a timed serializer with amax_agefor the staff session, and astatus = 'active'check inget_current_identity(one indexed read per request, or a short cache). - 2026-09-12 (measured at a556424,
core/security.py164 to 188,auth_link.py32 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.py106 to 114; D8): the contact lookup isLIMIT 1across 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.py85 to 125,portal.py290 to 320; D8): the PHI read audit is keyed onX-Dev-Tokenand onopenapi_extrax-data-class: phi; the portal routes carry aBearerand no tag, so a patient reading released results leaves no audit row, while a staff member reading the same result does. Fix it needs: anappend_auditinsideportal_resultsunder the sametenant_session,actor_type="patient". - 2026-09-12 (measured at a556424,
portal.py115 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.py44, 70 and 75; D8): the access-link redeem readsstaff.rolefrom the row (44) and then ignores it: therolefield and the re-minted staff token both takeidentity.rolefrom 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_refsandmfa_enforcedexist in the schema (001 69 to 70) and the six 501 stubs (four WebAuthn, step-up, logout;core/security.py324 to 378) stand where the ceremony would be; nothing writes either column. The one read ispasskey_countin the admin staff serializer (admin.py182,len(s.passkey_refs or [])), always 0 at a556424; nothing readsmfa_enforced. The inventory row's "no per-patient authorization on the patient surface yet" is not what the code does at a556424:/portal/meand/portal/resultsboth filter byidentity.patient_id(272, 300); the code wins. - 2026-09-12 (measured at a556424,
frontend/src/api/portalApi.ts6 andcore/security.py246 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.ts11 and 94 to 122; D8): the Playwrightportal mock (W5)block still walks/portalas a client-side mock (a four-digit code1234, ademo-neutoken) and the header says it "always runs";Portal.tsxhas called the real backend since 2ed301e (2026-07-11) and the verify model refuses anything but six digits (portal.py87). 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_devenvelope (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.jsonedges;scripts/check.mjs173 to 179): the interimfrontend_moduleslink toidentity-auth/ui/auth-screensputs a one-sided edge in the manifest (auth-screens to this recipe) that auth-screens' ownbackend_modulesdoes not return (B1 to B8 and B18; no B23), and its demo renders Labs' login, not/portalor/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 twostatus: unwrittenstubsclinic/ui/clinic-patient-portal-uiandclinic/ui/clinic-reception-uiwithbackend_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 tomodules/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.toml11, "CLOSED instance (2026-07-23)"; wikiplatform/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; theLogin.tsxclosed-access notice dates from the same change. - 2026-07-11 (61c6ec3): the portal ceremonies replaced 501 stubs; migration
003andtest_portal_auth.pylanded 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 andPortalIdentity(228 to 243),make_portal_sessionandparse_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) andmount_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.py182 (the onepasskey_refsread);backend/app/routers/website.py113 to 121 (_EXTERNAL_ROLESand the inline role check, the one role comparison outsiderequire_roles).backend/pyproject.toml5 to 16 (the floors; no lock file),backend/docker-compose.yml6 and 13 (postgres:16-alpineon 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_engine45,tenant_session53 to 66),backend/app/core/audit.py(81 lines;append_audit33 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). Wikiplatform/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.
python -m pytest tests/test_portal_auth.py -q -o addopts=""prints10 passed(skipped with the message naming the port when Postgres is not reachable). Measured 2026-09-12 at a556424:10 passed in 2.15s.grep -n "get_admin_engine()" app/routers/portal.py | wc -lprints4, and each is inside one of the four/portal/auth/*handlers;grep -c "get_admin_engine" app/routers/*.pyshows0for every other router.psql "$PRIMAOS_DATABASE_ADMIN_URL" -tAc "SELECT has_table_privilege('primaos_app','portal_auth_challenge','SELECT')"printsf;SELECT relrowsecurity FROM pg_class WHERE relname = 'portal_auth_challenge'printsf.- 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":"[email protected]"}'shows202, the sentenceFalls 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. POST /portal/auth/magic-link/redeemwith that link answers200with"scope":"portal:basic","token_type":"Bearer","expires_in":1800; the same call again answers410withDieser Link wurde bereits verwendet.; with{"link_token":"garbage"}it answers401.GET /portal/resultswith the basic bearer answers403andFür Befunde ist eine Bestätigung per SMS erforderlich;GET /portal/meanswers200withpatient.nameand anappointmentslist; without a bearer both answer401.POST /portal/auth/step-up/requestwith the basic bearer answers202with_dev.otp(six digits) in a dev build. Then one sequence of sixPOST /portal/auth/step-up/verifycalls with{"otp":"000000"}(when that is not the code) against that one code. At a556424 all six answer401Falscher Code., there is no429, and the right code sent seventh on the same row answers200with"scope":"portal:basic portal:results"(measured 2026-09-12, the first Gotcha). On a service that fixed the increment, calls one to five answer401Falscher Code.(the fifth leavesattempts = 5), call six answers429Zu viele Fehlversuche. Bitte fordern Sie einen neuen Code an., and only a fresh request plus the right code answer200with that scope. In both casesGET /portal/resultswith the results bearer is200.curl -s -i -X POST http://127.0.0.1:8000/internal/auth/webauthn/login/optionsshows501,content-type: application/problem+jsonandauthWebauthnLoginOptionsindetail;POST /internal/auth/logoutlikewise namesauthLogout.PRIMAOS_ENV=staging(a rebuilt process):GET /internal/dev/usersanswers404whenPRIMAOS_SPA_DIRis unset, and200withcontent-type: text/html(the SPA'sindex.html,main.py224 to 231) when a built SPA is mounted the wayfly.toml17 does, so an Argus check here asserts on the body carrying noitemsrather than on the status; the magic-link 202 carries no_dev;POST /internal/auth/access-link/redeemwith a token frommake_access_link_tokenanswers200with atokenfield, and the same call with the JSON keytoknanswers422(extra="forbid").python -c "from app.core.config import get_settings; print(get_settings().secret_key == 'dev-only-change-me')"withPRIMAOS_SECRET_KEYunset,PRIMAOS_ENV=stagingand no.envin the working directory that sets the key (pydantic-settings reads.envfrom the cwd,config.py18) printsTrueat a556424 (the first Gotcha); on a service that fixed it the import raises.- 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 1returns(true, true)when the link redeemed in step 5 was the last one issued in step 4. - In the SPA: opening
/portal?token=<link>redeems once, removestokenfrom the URL, and a reload does not call/redeemagain; 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.