Prima Get an API key

Module kit / Foundations

Auth dependency pattern

_shared/auth-dependency-pattern

Kind shared
Applies to backend
State written
Maturity prod
Reusable extract
Stack fastapi-0.118, ed25519-jws, cryptography-46, bcrypt-5, sqlalchemy-sync, postgres-15, redis-6
Depends on _shared/fastapi-project-layout
Reference implementation
  • alchemy_labs/backend/api/dependencies.py
  • alchemy_labs/backend/api/surface_gate.py
  • alchemy_labs/backend/core/surfaces.py
  • alchemy_labs/backend/core/jwt_ed25519.py
  • alchemy_labs/backend/core/jwt_keys.py
  • alchemy_labs/backend/core/session_cookies.py
  • alchemy_labs/backend/services/auth_service.py
  • alchemy_labs/backend/services/surface_service.py
  • alchemy_labs/backend/models/user_surface_grant_model.py
  • alchemy_labs/backend/models/api_key_model.py
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/api/routers/developer_router.py
  • alchemy_labs/backend/api/routers/jwks_router.py
  • alchemy_labs/backend/services/primaapi/identity.py
  • prima/prima/api/auth.py
  • prima/prima/auth/claims.py
  • prima/prima/auth/surfaces.py
  • prima/prima/auth/sessions.py
  • prima/prima/seams/identity.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-11
Also measured on
  • prima feature/per-model-pricing cd2c248 2026-09-11
  • philosophers_stone main d4f5b95 2026-09-11
Version 1

Purpose

A handler names the caller it needs (CurrentUser, ActiveUser, VerifiedUser, AdminUser, WorkerUser, OptionalUser) and gets a User row or a 401, with the credential read in one fixed order (Bearer header, then the access_token cookie, then X-API-Key) and the surface it belongs to already checked before the handler's own dependency runs. The invariant that makes it trustworthy is that authorisation is a row and not a claim: a token proves who signed it, user_surface_grants decides where that person may be, and the grant is read live on every gated request, so revoking a grant takes effect on the next call rather than at token expiry.

This foundation fixes the credential order, the dependency vocabulary, the v2 claim set every Labs token carries (iss, aud, ver, type, jti, iat, exp, sub, tids_n, optionally tid, aem, dsk), the Ed25519 signing rule with no algorithm dispatch, the opt-in API-key scope matrix with its prefix-locked gateway scopes, and the two doors the metered gateway accepts.

When to use

Include it from every kind: backend recipe that has an authenticated route. Read it before adding a dependency to a handler, before minting a token anywhere, before adding a scope, and before answering "why does a valid token get 403 here". Trigger phrases: "which dependency", "admin only", "API key or session", "the token is valid but", "where does the surface come from", "what is in the JWT", "can Prima verify this", "the key works on one host and not the other".

Do not use it for the workspace a route acts on (_shared/workspace-scoping, S6: WorkspaceID and X-Workspace-ID), for the HMAC seams between services (_shared/hmac-seam-pattern, S10: POST /internal/* is signed, never a session), for the login screens (identity-auth/ui/auth-screens, F1) or for the client-side half (_shared/react-app-skeleton, S2: /me.surfaces and the single-flight refresh). Passkeys, magic links, OIDC and the desk domain sign-in are identity-auth recipes B5 to B8 and B20; they all end in create_session, which is where this recipe starts.

Stack assumptions

Pinned as measured at fd8558f (2026-09-11): fastapi==0.118.2, cryptography==46.0.0 (the only signing dependency), bcrypt==5.0.0 (passwords, 72-byte truncation in hash_password), python-jose==3.5.0 kept for the JWTError exception type only (services/auth_service.py lines 14 to 19: it signs and verifies nothing, and tests/test_prima_ed25519_interop.py::test_no_hs256_signing_path_remains_in_auth_service greps to keep it so), redis==6.4.0 for the optional denylist. Foundations assumed: S4 (the surface gate is a mount-time dependency), S6, S7 (the 401 and 403 bodies are the Labs envelope; under /api/prima/v1 the OpenAI object).

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

NameDefaultBehaviour
JWT_SIGNING_KEY_SOURCEephemeralone of env, file, ephemeral; ephemeral refused in staging and production (validator 241 to 259; core/jwt_keys.py 263 to 270)
JWT_ED25519_PRIVATE_KEYempty SecretStrPKCS#8 PEM (literal \n accepted) or a base64url 32-byte seed; empty with source env raises at boot (jwt_keys.py 276 to 282)
JWT_ED25519_PRIVATE_KEY_PATHemptyread with source file; unreadable raises at boot (284 to 296)
JWT_ED25519_PUBLISHED_PUBLIC_KEYSempty listextra public keys published and accepted, never used to sign: the rotation mechanism (106 to 109; procedure in jwt_keys.py 39 to 63)
JWT_ISSUERthe Labs originthe iss claim; compared with != on both sides (113 to 116)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES60access lifetime; 15 when the denylist is on (auth_service.py 55, 428 to 432)
JWT_REFRESH_TOKEN_EXPIRE_DAYS30refresh lifetime and the sessions.expires_at horizon
JWT_LEGACY_NO_AUD_UNTILemptyISO-8601 instant until which a token with no aud is read as labs; empty means reject (127 to 130; auth_service.py 172 to 195)
JWT_DENYLIST_ENABLEDoffjti denylist in Redis on logout and password reset; off or Redis down means expiry only (auth_service.py 37 to 169)
ENABLE_EMAIL_VERIFICATION, REQUIRE_EMAIL_VERIFICATIONflagsVerifiedUser checks is_verified only when the first is on (dependencies.py 351 to 362); refresh refuses an unverified account only when both are on (auth_service.py 800 to 810)
ENVIRONMENTdevelopmentPOST /api/auth/dev-login exists only in development (auth_router.py 1685); cookies are Secure only in production (session_cookies.py 77)

Data model

Tables this layer owns (columns as declared in the models; the migrations are S8's):

-- the allowlist: no row, no access. models/user_surface_grant_model.py lines 40 to 62
user_surface_grants (
  user_id    uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  surface    varchar(32) NOT NULL,
  granted_at timestamp NOT NULL,
  granted_by uuid NULL,
  PRIMARY KEY (user_id, surface),
  CHECK (surface IN ('labs', 'prima_web', 'prima_chat', 'prima_dev'))   -- ck_usg_surface
);
CREATE INDEX ix_user_surface_grants_user_id ON user_surface_grants(user_id);

-- one row per minted pair. models/user_model.py lines 158 to 175
sessions (
  id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  access_token text UNIQUE NOT NULL, refresh_token text UNIQUE,
  ip_address varchar(50), user_agent text, is_active boolean, expires_at timestamp NOT NULL,
  created_at timestamp, last_activity timestamp, acting_email varchar(320) NULL
);

-- the key of record is the hash. models/api_key_model.py lines 25 to 60
api_keys (
  id uuid PRIMARY KEY, user_id uuid NOT NULL, workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  key_hash varchar(255) UNIQUE NOT NULL, key_prefix varchar(20) NOT NULL,
  permissions jsonb,                       -- {"scopes": [...]}; empty or absent means unlimited
  expires_at timestamptz NULL, is_active boolean NOT NULL DEFAULT true,
  max_uses integer NULL, uses_count integer NOT NULL DEFAULT 0, suspended_until timestamptz NULL
);
CREATE INDEX idx_api_keys_user_active ON api_keys(user_id, is_active);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX idx_api_keys_workspace ON api_keys(workspace_id);

What is not stored: the raw API key (returned once at mint, developer_router.py 128 to 131: prima_pk_ plus secrets.token_urlsafe(32), SHA-256 into key_hash, the first twelve characters plus an ellipsis into key_prefix); a JWT_ALGORITHM (there is none, core/config.py 69 to 79); a private key in the JWKS (jwt_ed25519.public_jwk has no code path that emits d, and test_jwks_contains_no_private_material asserts it against the live response).

The claim set every Labs token carries (auth_service.py::_mint, lines 366 to 405, and create_session, 683 to 769; commit ca227b9 for Ed25519 and v2, 2026-08-03; c923b8a for aem and dsk, 2026-09-05):

ClaimValueWho reads it
isssettings.JWT_ISSUERLabs (_decode_unverified_audience 511 to 516), Prima (allowlist, !=)
audone surface id, a string, never a listthe gate (decode_token_with_surface), Prima (enforce_audience)
ver2 (CLAIM_VERSION, line 228)Prima refuses ver < 2 (prima/auth/claims.py MIN_CLAIM_VERSION = 2, line 60)
typeaccess or refreshboth sides; a refresh token presented as a bearer credential is refused on both (test_a_refresh_token_is_refused_as_a_bearer_credential, 458)
jtiuuid4the denylist when on; Prima caches authorisation per jti
iat, expinteger secondsLabs checks exp (498 to 502); Prima checks both against 60 s skew and a 24 h ceiling (claims.py 63 to 68)
subusers.ideveryone
tids_nnumber of workspaces the subject owns or is an active member ofPrima's three-way tenant branch (_tenant_claims 230 to 276)
tidworkspaces.id, present only when tids_n == 1, never mappedPrima's tenant and metering hash chain; the docstring at 233 to 242 says why it is never re-keyed
aemthe acting email of a shared account, present only when it differs from users.emailrequest.state.acting_email, ActingEmail, the audit trail; never a permission (create_session 700 to 707, 729 to 731)
dsk1 when every active membership is a desk-only rolerequest.state.desk_only and the containment fence (create_session 733 to 749)

The JOSE header is {"alg": "EdDSA", "typ": "JWT", "kid": <thumbprint>} (jwt_ed25519.py 218); kid is the RFC 7638 thumbprint of the public key, so it is stable across restarts and computable by the verifier (test_kid_is_a_stable_thumbprint_of_the_key, 644). A token is at most 8192 bytes (MAX_TOKEN_BYTES, line 93); one minted 2026-09-11 with {sub} and no tenant was 440 bytes.

Request state written by the dependency (read by S6 and by handlers): request.state.surface, request.state.surface_grants (set by the gate, surface_gate.py 197 to 198), request.state.acting_email, request.state.desk_only (set by get_current_user, dependencies.py 211 to 219).

API surface

Routes this layer owns, all mounted on ALL_SURFACES at /api/auth unless noted (auth_router.py):

RouteAuth depTenancyCodesNotes
POST /api/auth/loginnone, auth_rate_limitnone200 {access_token, refresh_token, user}; 401 bad credentials; 403 disabled, no password, or no labs grant (_require_labs_grant, 140 to 152, called at 483)mints via create_session(surface="labs"); sets the two cookies
POST /api/auth/refreshnonenone200 new pair; 401 on any ValueError from refresh_sessionthe request is a JSON body {"refresh_token": "<token>"} (TokenRefresh, schemas/auth_schemas.py lines 42 to 43; the handler at auth_router.py 642 to 667 takes token_data: TokenRefresh and nothing else), so the refresh_token cookie on /api/auth is written by _set_auth_cookies and never read by this route, and a client in cookie mode still sends the body; inherits the old token's aud, re-reads the grant, re-derives aem and dsk from the session row (771 to 854)
POST /api/auth/logoutCurrentUsernone204revoke_session: session row inactive, both jti denylisted when the flag is on
GET /api/auth/meActiveUsernone200 UserResponse with surfaces: sorted(get_user_surfaces_standalone(user.id)) (711)the frontend's authorisation contract (S2)
POST /api/auth/ws-ticket?purpose=CurrentUsernone200 {ticket, expires_in}; 403 without a labs grant (1649); 400 bad purposethe surface check happens at issuance because the WebSocket handshake carries the ticket, not the JWT
GET /api/auth/cli-loginnonenoneredirect flowmints a scoped key; _require_labs_grant at 1557
POST /api/auth/dev-loginnonenone200 or 404registered only when ENVIRONMENT == "development" (1685); signs in the first admin
POST /api/developer/keysCurrentUserthe key's workspace_id201 with the raw key once; 400 on an unknown scope; 403 when a Prima-surface session asks for anything but ai (108 to 126)self-service scopes {read, write, delete, ai}, default ["read"] (developer_router.py 35 to 36)
GET /.well-known/prima-jwks.jsonnonenone200, 304jwks_router.py; max-age=120 under Prima's 300 s refresh (52); mounted before the catch-all

Error shape on the dependency's own refusals: HTTPException with a string detail, rendered by S7's handler as {error, code: INVALID_CREDENTIALS or FORBIDDEN, request_id, timestamp}; the 401 carries WWW-Authenticate: Bearer (dependencies.py 194 to 198). Detail strings a client can key on: "Could not validate credentials" (any bad or missing credential, on purpose one string), "User account is disabled" (403), "Inactive user", "Please verify your email address", "Admin access required", "Worker access required", "API key is deactivated" (401), "API key scope does not permit <METHOD> on this resource." (403), "This API key is restricted to the <scope> agent API (<prefix>)." (403), {"error": "desk_only_role"} (403, a dict detail), and from the gate "This session belongs to the '<surface>' surface and cannot be used here.", "This account is not permitted on this surface." (403) and "Access control is temporarily unavailable." (503).

The gateway door (D3: prima is the reference; the in-Labs copy is legacy and is described only where it differs): CurrentIdentity = Annotated[Identity, Depends(current_identity)] (prima/api/auth.py 117 to 144) takes Authorization, X-API-Key and X-Workspace-ID as headers and returns an Identity with subject_id, tenant_id, issuer, auth_method, scopes, surfaces, key_id, degraded, subject_active, cached_until, aud and no PII (prima/seams/identity.py 284 to 313). GET /api/prima/v1/whoami (152 to 184) returns that plus identity_provider: {federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state}. In Labs the legacy equivalent is GatewayCtx = Depends(gateway_identity) on primaapi_router.py line 55, resolving through services/primaapi/identity.py::resolve_gateway_identity (193 to 236) to a GatewayIdentity(user, tenant_id, api_key, auth_method) (63 to 68).

Application logic

get_current_user(request, db) (dependencies.py 187 to 266), in order:

  1. _extract_token(request) (35 to 50): Authorization: Bearer <t> wins; else the access_token cookie; else none.
  2. With a token: auth_service.decode_token_with_surface(token, token_type="access") (547 to 557), which is signature against the published key set (_verify_signature, 474 to 491: InvalidToken becomes one uniform JWTError("Invalid token")), then exp, iss, type, denylist (_decode_unverified_audience, 493 to 522), then resolve_token_surface (524 to 545: no aud is labs inside the legacy window and a JWTError outside it; a list is refused; an unknown or external surface is refused). The dependency reads the surface rather than asserting one because which surfaces are acceptable is the router's decision (comment at 203 to 207). request.state.surface, acting_email and desk_only are set from the payload; a missing sub or any JWTError is the uniform 401.
  3. auth_service.get_user_by_id(sub); no row is the uniform 401; not user.is_active is 403.
  4. _enforce_desk_only_containment(request, dsk) (160 to 184): a desk-only account outside /api/desk/ and /api/auth/ (services/service_desk/access.py::desk_only_path_allowed, 405 to 419) is 403 {"error": "desk_only_role"}. Return the user.
  5. Without a token, X-API-Key if present: _authenticate_via_api_key (53 to 157): SHA-256 the value, look up api_keys.key_hash (never the prefix); unknown key returns None and the caller 401s; not is_active is 401 "API key is deactivated"; then the two scope rules below; then the owner row (None if missing); then api_key_security_service.check_api_key_security (expiry with auto-suspend, suspension, IP allow-list, use count, anomaly; a False is 401 with its message); then not user.is_active is 403. The desk-only question is asked with one query because there is no dsk claim, and DeskAccessUnavailable is 503 (241 to 258).
  6. Neither credential: the uniform 401 with WWW-Authenticate: Bearer.

The two API-key scope rules (both in _authenticate_via_api_key):

  • Prefix lock (70 to 105): _GATEWAY_SCOPES = {"airiel": "/api/airiel/", "aurum": "/api/aurum/", "ai": "/api/prima/v1/", "mail": "/api/mail/"}. A key whose scopes carry one of these is refused with 403 on any path outside that prefix. Opt-in per key; a key without such a scope is unaffected.
  • Method matrix (108 to 127): scopes are enforced only when the key declares any (empty means unlimited, for keys minted before scopes existed); admin, full or * grants everything; otherwise GET, HEAD, OPTIONS need one of read, write, delete; DELETE needs delete or write; every other verb needs write. airiel is excluded from the enforceable list and aurum deliberately is not, so an aurum key that somehow passed the prefix lock still fails every method (the comment at 113 to 119 explains the asymmetry).

The derived dependencies (338 to 386) each take the one below: ActiveUser re-checks is_active (403 "Inactive user"); VerifiedUser adds is_verified when ENABLE_EMAIL_VERIFICATION; AdminUser adds is_admin; WorkerUser admits is_admin or role in ("designer", "delivery"). OptionalUser (388 to 423) decodes a token if present and returns None on any failure; a desk-only account outside its allow-list is None here, not 403, so a public page keeps working for someone signed in at a desk in the same browser. OptionalUser reads no API key. ActingEmail (782 to 794) is request.state.acting_email or user.email, lowercased.

Before any of that runs, the mount-time gate (surface_gate.py::SurfaceGate.__call__, 108 to 199): read the same credential (Bearer, cookie, then X-API-Key, 74 to 79 and 113 to 114); anonymous passes; an undecodable token passes so the endpoint produces the 401 rather than the gate leaking a 403; a token whose aud is not in the router's set is 403; then get_user_surfaces(db, sub) (surface_service.py 33 to 50, the only reader of the table, fail-closed with SurfaceGrantError becoming 503); an API key is not surface-bound, so its owner must hold a grant for at least one surface the router opens, and the lowest-sorted such surface becomes request.state.surface (174 to 187); a token's aud must itself be granted (188 to 195). The key lookup the gate calls is _api_key_user_id(db, value) (94 to 104): SHA-256 the value, SELECT user_id FROM api_keys WHERE key_hash = :h AND is_active = TRUE, None when nothing matches, and on None the gate returns without a decision (158 to 161) so the endpoint's own dependency answers the uniform 401. A service without an API-key door keeps the function and returns None unconditionally; the gate then passes every X-API-Key through to the endpoint, which has no key path and answers the 401. SurfaceExempt("public") delegates to a gate over all four surfaces so a credential presented on a public router is still checked, and own_auth is a true no-op (226 to 242).

Minting (auth_service.py): create_session(user_id, ip, ua, *, surface, acting_email=None) (683 to 769) asserts the surface is issuable, reads the grants and raises SurfaceAccessDenied when the surface is not among them (the choke point every login flow shares, including magic links and password resets), trims sessions per (user_id, acting_email) to MAX_SESSIONS_PER_USER, builds {sub, aem?, dsk?}, mints access and refresh with the same key and the same aud, and inserts the sessions row. refresh_session (771 to 854) decodes the refresh token with its surface, re-reads the grant, finds the active row by the refresh token value, re-derives aem from the row and dsk from the memberships, and mints a new pair with the same surface. create_access_token and create_refresh_token take surface as a mandatory keyword; omitting it is a TypeError (test_minting_a_token_without_a_surface_is_a_typeerror, 209).

Cookies (core/session_cookies.py): access_token on path /api, refresh_token on path /api/auth, HttpOnly; same-site with the surface's cookie_domain, SameSite=Lax, Secure in production; cross-site with no domain, SameSite=None; Secure, and the tokens also in the body because the browser may drop the cookie (64 to 113 and the header comment). The SPA uses the body tokens (S2, cookie mode off).

The gateway door on Prima (prima/seams/identity.py::resolve, 567 onward): a Bearer that decodes is honoured first; a Bearer that does not decode falls through to X-API-Key when one is present, and when none is present the Bearer value itself is tried as the key, because every OpenAI SDK sends Authorization: Bearer <key> and cannot be stopped without a sentinel (the comment at 580 to 596; the same fix in Labs at identity.py 197 to 213). A key must carry ai (authorize_gateway, 459 onward; GATEWAY_SCOPE), a wildcard is a migration defect and is refused, and a suspended subject is refused on both doors from prima_subjects.status read fresh on every request (FIND-011). A JWT is verified offline against the disk-cached JWKS and then authorised once per jti through an introspector; at cd2c248 current_identity passes introspector=None (prima/api/auth.py 137), so a Labs Bearer with no cached session answers 503 identity_provider_unavailable with Retry-After: 30 (prima/auth/sessions.py 100 and 222 to 231). Only the X-API-Key door is complete end to end today (Gotchas).

Invariants

  • Credential order is Bearer, then cookie, then X-API-Key, in the dependency (35 to 50, 223 to 258), in the gate (74 to 79, 113 to 114) and in _resolve_request_user_id (576 to 619); a request with both a JWT and a key is the JWT.
  • One algorithm, checked by equality: header["alg"] != "EdDSA" fails in jwt_ed25519._split (line 248) and there is no accepted-algorithm list anywhere (_verify_signature docstring, 475 to 482). alg: none and HS256 signed with the public key are refused by tests 299 to 359 of the interop file.
  • No token without a surface: _mint, create_access_token, create_refresh_token, create_session and decode_token all take surface or expected_surface as a mandatory keyword. decode_token_with_surface is the only reader that does not assert one, and it is called only by code that compares the result against a set.
  • No grant, no access: get_user_surfaces is the one reader of user_surface_grants (test_the_grants_table_is_read_in_exactly_one_module, 716), it filters rows through ISSUABLE_SURFACES so a retired surface's row re-opens nothing (48 to 50), and every error becomes a deny.
  • Grants are read live on every gated request and at every refresh, never carried in the token (refresh_session 785 to 790; wiki platform/architecture.md "Surfaces", 2026-08-02).
  • A refresh inherits the presented token's aud; it cannot launder one surface into another (test_refresh_cannot_launder_a_prima_session_into_a_labs_one, 568).
  • aem is a label, dsk is a fence; nothing authorises on aem (_acting_email docstring), and dsk is re-derived at refresh so a role change lands at the next refresh, not at expiry (830 to 842).
  • The API key of record is its SHA-256; lookup is by hash, never by prefix, in the dependency (61 to 64), the gate (97 to 104) and Prima.
  • An empty scope list means unlimited, for keys that predate scopes; a key that declares scopes has them enforced; a gateway scope locks the key to its prefix. POST /api/developer/keys refuses an empty or unknown scope list and never mints a gateway scope other than ai (test_gateway_scopes_are_not_mintable, test_self_service_keys_are_never_unscoped).
  • A Prima-surface session mints ai-only keys, whatever it asked for (developer_router.py 108 to 126).
  • The uniform 401: every bad credential, missing credential, unknown kid, bad signature and missing sub answers "Could not validate credentials"; only a known, active key or user distinguishes its refusal (deactivated, disabled, scope, prefix, desk-only).
  • dev-login cannot exist in a production process (registered under if settings.ENVIRONMENT == "development"), and the SPA's call to it is dead in a production bundle (S2).

Frontend

The frontend modules that render this layer, by id, and what each shows of it:

identity-auth/ui/auth-screens                  login, magic link, passkey, reset: every path ends in create_session;
                                               the 403 "not permitted to sign in" is the missing labs grant
identity-auth/ui/profile-and-passkeys          sessions list, change password, passkeys per relying party
identity-auth/ui/api-keys-settings-and-developer-dashboard
                                               scope picker over _SELF_SERVICE_SCOPES, raw key rendered once into one node
identity-auth/ui/admin-users-console           grants per surface as a column, appoint and revoke admin
identity-auth/ui/developer-portal-keys-page    the ai-only mint from a prima_dev session
identity-auth/ui/chat-signin-and-workspace-picker
                                               the per-session gateway key the chat server mints (B21)
ui-design-system/ui/appshell-navigation        /me.surfaces decides what the shell shows; PrivateRoute's
                                               "This account can't be used here" is hasSurface false (S2)

Minimum admin and debug surface: GET /api/auth/me (the grants as surfaces), GET /api/prima/v1/whoami on the gateway (which door, which tenant, how fresh), the JWKS document, and the boot log line JWKS mounted at /.well-known/prima-jwks.json (alg=EdDSA, source=<env|file|ephemeral>, active kid=<kid>, N key(s) published) (app_fastapi.py 1066 to 1073). The three gate refusals log a warning with the path, the surface and the grants (surface_gate.py 143 to 148, 179 to 185, 189 to 194).

Security & tenancy

Auth dependency: this recipe. Workspace derivation: S6, which reads request.state this layer writes and re-validates X-Workspace-ID for the API-key path. Fail direction, per seam: signature, expiry, issuer, type and audience closed; grants closed (503 on a lookup error); desk-only closed (503 when the question cannot be answered, both at mint and in the dependency); API-key security checks closed in the main dependency and open in the legacy in-Labs gateway (services/primaapi/identity.py 175 to 182 logs and continues on an exception); the jti denylist open (off by default, open on a Redis error: is_jti_revoked 147 to 169); the legacy no-aud window closed when unset.

Credentials at rest: the private key in an environment variable or a file, never in the database and never logged (--generate prints it once); the JWKS carries public members only; sessions.access_token and refresh_token are stored verbatim and unique, which is what revoke_session and refresh_session look up by; API keys as SHA-256; passwords as bcrypt with 12 rounds (BCRYPT_ROUNDS).

Rate limits on the credential-issuing routes: auth_rate_limit at 30 per minute per client IP and 10 per minute per email (api/dependencies/rate_limit.py 26 to 34), 429 with Retry-After; TRUSTED_PROXY_COUNT decides which X-Forwarded-For hop is the client.

Attacker-controlled inputs: the Authorization header and the cookie (bounded at 8192 bytes before base64 decoding); X-API-Key (hashed before any lookup); the JOSE header (alg equality, kid looked up in a dict and never scanned, test_unknown_kid_is_rejected_rather_than_scanning_every_key, 409); a list-valued aud (refused); X-Workspace-ID (S6); the Origin and Host headers on the cookie path (is_same_site, session_cookies.py 49 to 61, a suffix match against cookie_domain).

Tenancy of the credential: a JWT belongs to one surface (aud) and to a user; the workspace is not in the token except as tid when unambiguous, and tid is read by Prima only. An API key belongs to a user and to one workspace (api_keys.workspace_id), and the gateway's tenant is that column (identity.py 190; Prima prima_api_keys likewise). A key minted from a Prima surface reaches the gateway and nothing else; a key minted from Labs with ai and other scopes reaches the gateway under the prefix lock and the rest of the API under the method matrix.

Tests

Backend (alchemy_labs/backend/tests/, run 2026-09-11 at fd8558f against the :5434 Postgres):

  • test_surface_scoped_access.py and test_surface_rp_id.py: 84 passed in 8.27s. The cases a new service copies: the two TypeError tests (209, 218), test_a_prima_token_does_not_validate_as_a_labs_token (249), the legacy window pair (258, 274), test_a_token_naming_an_unknown_surface_is_rejected (289), test_api_only_session_is_denied_on_every_labs_gated_route (354, walks the real route table), test_api_only_session_403s_over_real_http (400, parametrised), test_self_service_keys_are_never_unscoped (440), test_login_requires_an_explicit_labs_grant (544), test_create_session_refuses_a_surface_the_user_has_no_grant_for (557), test_refresh_cannot_launder_a_prima_session_into_a_labs_one (568), test_a_labs_user_may_still_use_the_prima_gateway (638), test_every_route_carries_a_surface_decision (680), test_the_grants_table_is_read_in_exactly_one_module (716).

  • test_prima_ed25519_interop.py and test_admin_api_key_scopes.py: 59 passed in 3.52s. Known-answer vectors: a Labs-minted token verified by Prima's verifier (160), tid equals the workspace id unmapped (217), tids_n against membership (235), the algorithm refusals (299 to 359), the rotation sequence (576 to 625), test_labs_and_prima_agree_on_the_surface_vocabulary and _on_the_algorithm_name (698, 708).

  • A known-answer vector for the primitive itself, so a rebuilt jwt_ed25519.py can be shown to interoperate with Prima's verifier before any service runs (computed 2026-09-12 with core/jwt_ed25519.py at fd8558f and verified with prima/auth/jwt_ed25519.py::verify at cd2c248; the twin files to carry it are alchemy_labs/backend/tests/test_prima_ed25519_interop.py and prima/tests/auth/test_jwt_ed25519.py, each with the comment naming the other). Seed 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f (32 bytes, bytes(range(32)); as a JWT_ED25519_PRIVATE_KEY value it is the base64url seed AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8); public x A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg; kid 1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y (43 characters); payload {"aud":"labs","exp":1800000600,"iat":1800000000,"iss":"https://issuer.example","jti":"00000000-0000-4000-8000-000000000002","sub":"00000000-0000-4000-8000-000000000001","tids_n":0,"type":"access","ver":2}; the compact token, 466 characters:

    eyJhbGciOiJFZERTQSIsImtpZCI6IjFJRzJ0TUg3SjJ3Ykpabk9mOExKelFpdEtmN0xNdm9BRWxzdURNVk01NFkiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJsYWJzIiwiZXhwIjoxODAwMDAwNjAwLCJpYXQiOjE4MDAwMDAwMDAsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUiLCJqdGkiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIiLCJzdWIiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEiLCJ0aWRzX24iOjAsInR5cGUiOiJhY2Nlc3MiLCJ2ZXIiOjJ9.HmXLKLkui_V4er4rRca5RVjWuKDDMlbL7rqgokt7f3B5_9JfSD7svPXrpLk4MRIFFdUcK955QOimBKuJTKRQAA
    

    The token is reproducible because Ed25519 signs deterministically and header and payload are both compact JSON with sorted keys (_compact_json, jwt_ed25519.py line 132; the header is {"alg":"EdDSA","kid":<kid>,"typ":"JWT"}). encode(claims, private_key=Ed25519PrivateKey.from_private_bytes(seed), kid=thumbprint_kid(public_key)) must print it byte for byte; verify_signature(token, {kid: public_key}) on Labs and verify(token, jwk_to_public_key({"kty":"OKP","crv":"Ed25519","x":<x>,"kid":<kid>})) on Prima must both return the payload above; a change to any constant is a wire break the twin catches.

  • test_desk_acting_email.py: aem and dsk end to end, including OptionalUser returning None off the desk allow-list (592 to 604).

  • test_webauthn_multi_rp.py, test_primaapi_gateway.py, test_aurum_gateway.py: the neighbours (B7, the legacy gateway, the prefix lock for aurum).

Prima (prima/tests/auth/, run 2026-09-11 at cd2c248 with python -m pytest tests/auth -q -o addopts=""): 232 passed, 21 skipped in 5.99s (the skips are test_repository_live.py, which needs a database). Load-bearing: test_import_graph.py (no module on the key path can reach Labs, exactly one module imports labs_internal, no auth module imports a JWT library), test_labs_down.py (the degradation table: identity_provider_unavailable with Retry-After: 30), test_gateway_scope_uniform.py (an ai key is authorised the same on every channel; a wildcard is refused), test_subject_suspension.py (FIND-011), test_claims_and_surfaces.py (28 cases on the claim floor and audience rules).

Hermetic versus live: everything above is hermetic apart from the 21 skipped Prima tests; no test reaches a deployed host. Playwright gate: frontend/e2e/auth.spec.ts (S2) walks login, refresh and the not-permitted screen.

Deploy & ops

Config keys per app heredoc (S13): JWT_SIGNING_KEY_SOURCE=env and JWT_ED25519_PRIVATE_KEY (one line, base64url seed or PEM with literal \n), JWT_ISSUER equal to Prima's PRIMA_JWT_ISSUER byte for byte, JWT_ED25519_PUBLISHED_PUBLIC_KEYS empty until a rotation, JWT_LEGACY_NO_AUD_UNTIL empty (reject), JWT_DENYLIST_ENABLED as decided (off today), MAX_SESSIONS_PER_USER, the two verification flags, and on Prima PRIMA_JWKS_URL pointing at the API host's /.well-known/prima-jwks.json (not the SPA host, which answers 200 with index.html for any path; deploy/infomaniak/PRIMA_JWKS_INGRESS.md records the trap).

Migrations order: user_surface_grants before any code that mints (account creation writes the grant row in the same transaction as the user row, models/__init__.py 80 to 84); sessions.acting_email before c923b8a's code. Both are in the chain at fd8558f (S8).

Rotation, from core/jwt_keys.py lines 39 to 63: publish the new public key in JWT_ED25519_PUBLISHED_PUBLIC_KEYS and deploy; wait at least Prima's refresh interval (300 s); move the new private key into JWT_ED25519_PRIVATE_KEY and the old public key into the published list and deploy; after JWT_ACCESS_TOKEN_EXPIRE_MINUTES plus skew, remove the old key and deploy. A compromised key is steps three and four back to back, which signs out everyone holding a token from it. test_rotation_publishes_both_keys_and_signs_with_exactly_one (576) and the two that follow pin the sequence.

Scheduler jobs: none owned here; session cleanup is _cleanup_user_sessions at mint time. Argus checks: the JWKS document returns 200 with keys[0].kty == "OKP" and crv == "Ed25519" and no d; GET /api/auth/me with a known session returns surfaces containing labs; the gateway's whoami reports jwks_keys >= 1. Rollback: a key change is configuration, reverted by redeploying the previous values; a token minted by a key that is no longer published is a 401 for its holder, which is the documented outcome of step four.

Gotchas

  • 2026-09-11 (measured at fd8558f, code wins over the wiki): platform/architecture.md (d4f5b95) "Authentication & Authorization" says JWT is HS256 via python-jose with access 1 h and refresh 30 d. Since ca227b9 (2026-08-03) tokens are Ed25519 JWS signed by core/jwt_ed25519.py; python-jose is imported for JWTError only, JWT_SECRET_KEY signs nothing, and there is no JWT_ALGORITHM setting (core/config.py 60 to 79). The lifetimes are right.
  • 2026-09-11 (measured at fd8558f, code wins over the wiki): platform/primaapi.md (d4f5b95, "Open items") says Labs' half of the auth seam does not exist in any branch and names /.well-known/prima-jwks.json among the missing pieces. The JWKS endpoint has been in Labs since ca227b9 (api/routers/jwks_router.py, mounted at app_fastapi.py 1050 to 1062). The other two pieces the page names, an introspection endpoint and a revocation feed, do not exist at fd8558f (grep -rln introspect under backend/ outside tests hits only services/primalayered), so Prima's Bearer door still runs with introspector=None and answers 503 identity_provider_unavailable. Prima's own prima/auth/wiring.py header (cd2c248) still says Labs signs HS256 and that none of the four endpoints exists; one of the four does.
  • 2026-09-11 (measured at cd2c248): prima/auth/wiring.py line 24 and tests/auth/test_labs_down.py line 17 describe the unavailable-provider answer as 401; _unavailable() in prima/auth/sessions.py (222 to 231) builds it with status 503. The test asserts the code and the header, not the status. The code wins: 503 with Retry-After: 30.
  • 2026-09-11 (measured at fd8558f): JWT_DENYLIST_ENABLED defaults to off and the wiki (platform/primaapi.md, "The auth seam, as built") records that it is set nowhere on the estate, so revoke_jti is a no-op and logout does not end a still-valid access token; with the flag on, a Redis error fails open (is_jti_revoked 164 to 169). The session row goes inactive at logout, which stops refresh but not the access token for its remaining life (60 min; 15 min once the flag is on).
  • 2026-09-11 (measured at fd8558f): the wiki's own note (platform/architecture.md, verified 2026-09-04) that API-key scopes are enforced opt-in per key, by HTTP method, is what the code does (dependencies.py 108 to 127). Two consequences worth naming: POST /api/developer/keys defaults to ["read"], so a key minted without asking for ai authenticates on the gateway and then fails every completion with 403 "This API key does not carry the 'ai' scope required for PrimaAPI." (legacy identity.py 150 to 158), which reads as a broken gateway; and a key with permissions unset is unlimited, which GET /api/auth/cli-login used to produce (prima/seams/identity.py 158 to 162 records it; test_self_service_keys_are_never_unscoped now guards the developer route).
  • 2026-09-11 (measured at fd8558f): the legacy in-Labs gateway door (services/primaapi/identity.py) is the one that answers on the Labs hosts; it accepts a wildcard scope (_grant_all = {"admin", "full", "*"}, 151 to 152) that Prima's prima_api_keys refuses by CHECK constraint (ck_prima_api_keys_no_wildcard, prima/auth/models.py 244 to 246), and its security-service call fails open (175 to 182). Both are why D3 names the standalone service as the reference: a new gateway copies prima/seams/identity.py, not this file.
  • 2026-09-11 (measured at fd8558f): SurfaceExempt("public") did no checking on 12 mounts until the 2026-08-02 audit (surface_gate.py 226 to 233 tells the story; wiki operations/security-audit-2026-08-02.md B1). It now delegates to a four-surface gate. A public router can therefore still refuse a credential: a token minted for a surface its owner is not granted is 403 on a beacon path, which is correct and surprising.
  • 2026-09-11 (measured at fd8558f): the api/dependencies/ package shadows api/dependencies.py (S4 Gotchas); from api.dependencies import CurrentUser resolves through api._dependencies_impl. A test that patches api.dependencies.auth_service patches the wrong module object.
  • 2026-09-11 (measured at fd8558f): get_current_user logs the first 20 characters of every presented token at INFO (dependencies.py 204) and the decoded user id (223). A new service logs neither; the jti is the handle.
  • 2026-09-11 (measured at fd8558f): RateLimitDependency and rate_limit_strict in the same file are placeholders (S4 Gotchas); auth_rate_limit in api/dependencies/rate_limit.py is the real one and is what /login, /register, /forgot-password, /reset-password, /request-magic-link, /resend-verification, /verify-email and /set-password declare.
  • 2026-09-11 (measured at fd8558f): the surface question for a WebSocket is asked at POST /api/auth/ws-ticket (a labs grant, 1649), not at the handshake; websocket_router is mounted surface_exempt("own_auth") for that reason (app_fastapi.py 880). A Prima realtime feature needs its own purpose and its own grant check, not a widening of this one.
  • 2026-09-11 (measured at fd8558f): check_ai_access and AIAccessUser (489 to 543) gate the Labs copilot on client_status and the metal tier with 402 and 403; zero routers declare them (grep over api/ and services/). They are the pre-D2 billing gate; D2 (2026-09-11) makes the prepaid credit in Prima's ledger the one truth for AI spend, so a new AI route meters through the gateway and does not copy this dependency.
  • 2026-09-11 (measured at fd8558f): the inventory's row B6 names a second password-change flow in user_router.py (a six-digit code held in a process-local dict); D8 asks to retire it. Until then two ways to change a password exist and only /api/auth/change-password and /reset-password revoke sessions. Not re-measured here; the row is the source.
  • 2026-09-05 (c923b8a, the Surface attribute saga recorded in core/surfaces.py 53 to 122 and wiki platform/architecture.md): the WebAuthn service read rp_id, rp_name and origins off a Surface that declared none of them; each was added only when a live 500 exposed it, and passkey registration was broken on every surface from 2026-08-29 to 2026-09-04. The three are @property aliases now with tests/test_surface_rp_id.py covering every surface. When a fix adds one attribute, grep every attribute the caller reads.

Reference implementation

Paths are relative to each repo's root; commits as in measured_on and also_measured_on.

  • alchemy_labs (branch d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • backend/api/dependencies.py: _extract_token (35 to 50), _authenticate_via_api_key (53 to 157; _GATEWAY_SCOPES 70 to 96, method matrix 108 to 127, security service 139 to 146), _enforce_desk_only_containment (160 to 184), get_current_user (187 to 266), get_db_with_rls (268 to 336), get_current_active_user (338), get_current_verified_user (351), get_current_admin_user (364), get_current_worker_user (377), get_optional_current_user (388 to 423), check_ai_access (489 to 543), the Annotated aliases (565 to 574), _resolve_request_user_id (576 to 619), _acting_email and ActingEmail (782 to 794). Call sites at fd8558f: CurrentUser 202, ActiveUser 172, AdminUser 173, VerifiedUser 58, WorkerUser 3, Depends(get_current_user) 204, Depends(get_db) 181, DatabaseSessionWithRLS 22.
    • backend/api/dependencies/__init__.py (the shadow, 4 to 39), backend/api/dependencies/rate_limit.py (26 to 34, auth_rate_limit 38), backend/api/dependencies/surface.py (resolve_surface: explicit id, then Origin, then Referer, then labs).
    • backend/api/surface_gate.py: SurfaceGate (63 to 199), SurfaceExempt (202 to 252), mount_router (265 to 274).
    • backend/core/surfaces.py: the five ids (34 to 38), Surface with the three aliases (41 to 122), SURFACES (125 to 166), ISSUABLE_SURFACES, PRIMA_SURFACES, LABS_SURFACES, ALL_SURFACES, LABS_SIGNUP_SURFACES, API_ONLY_SURFACES (169 to 189), assert_issuable (211 to 220), validate_surfaces (223 to 235).
    • backend/core/jwt_ed25519.py: ALG (78), MAX_TOKEN_BYTES (93), InvalidToken (97), public_jwk (141), thumbprint_kid (167), encode (206 to 227), _split with the equality check (229 to 267, line 248), peek_kid (269), verify_signature (280 onward).
    • backend/core/jwt_keys.py: the source rule and rotation procedure (1 to 63), KeyConfigurationError (107), load_private_key (127), generate_private_key_pem (180), SigningKey and KeySet (199 to 243), build_key_set (245 to 325), get_key_set (333 to 344), --generate (353 to 368).
    • backend/core/session_cookies.py: names and paths (32 to 35), is_same_site (49 to 61), set_auth_cookies (64 to 113).
    • backend/services/auth_service.py: denylist (37 to 169), legacy window (172 to 195), CLAIM_VERSION and _tenant_claims (200 to 305), Redemption (308 to 325), SurfaceMismatch, SurfaceAccessDenied (328 to 337), _mint (366 to 405), create_access_token (407 to 440), create_refresh_token (442 to 472), _verify_signature (474 to 491), _decode_unverified_audience (493 to 522), resolve_token_surface (524 to 545), decode_token_with_surface (547 to 557), decode_token (559 to 577), create_session (683 to 769), refresh_session (771 to 854), revoke_session (856 to 876).
    • backend/services/surface_service.py: SurfaceGrantError (29), get_user_surfaces (33 to 50), user_has_surface (53 to 57), grant_surfaces (60), revoke_surfaces (95), the standalone pair (114 to 132).
    • backend/models/user_surface_grant_model.py (40 to 62), backend/models/user_model.py (Session 158 to 175), backend/models/api_key_model.py (25 to 60).
    • backend/api/routers/auth_router.py: _require_labs_grant (140 to 152), login (436, grant at 483), refresh (642), logout (671), me (697 to 732), cli_login (1510), create_ws_ticket (1623 to 1660), dev_login (1685 onward).
    • backend/api/routers/developer_router.py: _SELF_SERVICE_SCOPES (35), the Prima-surface rule (108 to 126), the mint (128 to 131).
    • backend/api/routers/jwks_router.py: _MAX_AGE_SECONDS (52), JWKS_PATH (57), the handler (60 onward).
    • backend/services/primaapi/identity.py (legacy, D3): GatewayIdentity (63 to 68), _authenticate_ai_scoped_key (139 to 190), resolve_gateway_identity (193 to 236); backend/api/routers/primaapi_router.py GatewayCtx (55).
    • backend/services/service_desk/access.py: DeskAccessUnavailable (75), is_desk_only_user (367), desk_only_path_allowed (405 to 419).
    • Tests as listed under Tests.
  • prima (feature/per-model-pricing, cd2c248): prima/api/auth.py (current_identity 117 to 144, CurrentIdentity 144, whoami 152 to 184), prima/auth/claims.py (floors 60 to 74, Claims 84 to 102), prima/auth/surfaces.py (GATEWAY_SURFACES 83, enforce_audience 93 onward), prima/auth/sessions.py (RETRY_AFTER_S 100, _unavailable 222 to 231, _revoked 234 to 239), prima/auth/jwt_ed25519.py (the mirror-image verifier), prima/auth/wiring.py (the one module that may import labs_internal), prima/seams/identity.py (Identity 284 to 313, authorize_gateway 459 onward, resolve 567 onward, _resolve_jwt 691 onward), tests/auth/.
  • Wiki philosophers_stone (d4f5b95): platform/architecture.md ("Authentication & Authorization", the scope note verified 2026-09-04, "Surfaces", the passkey saga), platform/primaapi.md ("The auth seam, as built", "Self-serve signup mints a key Prima cannot see", "Open items"), operations/decisions/0020-prima-identity-and-prepaid-credits.md (section 1 surfaces, the ai scope), operations/decisions/0022-prima-extraction.md, operations/prima-modules-inventory.md (rows B2, B3, B4, B10, B11, B13, B20 and S5).

Deviations of the reference from this recipe, all dated 2026-09-11: the denylist off and open; the legacy gateway's wildcard and fail-open security call; token prefixes logged at INFO; the placeholder rate-limit dependency; the second password-change flow; check_ai_access present with zero callers; the introspection endpoint and revocation feed absent, so the Prima Bearer door is 503 by design; three stale docstrings (Prima's wiring header, the test table's 401, the Caddy note the wiki quotes).

Acceptance checks

Run from alchemy_labs/backend at the commit in measured_on with the :5434 test Postgres up, or from a new service built to this recipe. Expected observations are exact.

  1. python -m pytest tests/test_surface_scoped_access.py tests/test_surface_rp_id.py -q --no-cov -o addopts="" prints 84 passed; python -m pytest tests/test_prima_ed25519_interop.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts="" prints 59 passed. In prima: python -m pytest tests/auth -q -o addopts="" prints 232 passed, 21 skipped.
  2. grep -n "user_surface_grants" -r --include='*.py' . | grep -v "tests/\|migrations/\|models/user_surface_grant_model.py\|services/surface_service.py" | wc -l prints 0 for SQL reads (the remaining hits are comments and the model registration in models/__init__.py; test_the_grants_table_is_read_in_exactly_one_module is the authoritative form of this check).
  3. grep -n "JWT_ALGORITHM\|jwt.encode\|jwt.decode\|jose.jwt" services/auth_service.py core/jwt_ed25519.py | wc -l prints 0; grep -c '!= ALG' core/jwt_ed25519.py prints 1.
  4. python -c "from services.auth_service import auth_service; auth_service.create_access_token({'sub': 'x'})" (with JWT_SIGNING_KEY_SOURCE=ephemeral and the test environment) raises TypeError naming surface; python -c "from services.auth_service import auth_service; auth_service.decode_token('x')" raises TypeError naming expected_surface.
  5. With the app running and a session for a user who holds labs only: curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8000/api/auth/me returns "surfaces":["labs"]; delete the user's grant row and repeat: the response is 403 with "This account is not permitted on this surface."; restore the row and the same token works again (no re-login). On a new service the route is its own /api/auth/me and the 403 is observed on any route mounted on LABS_SURFACES.
  6. With a token minted for prima_web (a Prima magic link, or create_session(..., surface="prima_web") in a shell): curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8000/api/forms/templates is 403 with "This session belongs to the 'prima_web' surface and cannot be used here."; curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8000/api/prima/v1/models is 200. On a new service the first call is any route mounted on LABS_SURFACES (the sentence is exact); the second applies only when the service mounts a router on ALL_SURFACES, where the same token is admitted.
  7. curl -s -H "Authorization: Bearer not-a-token" http://127.0.0.1:8000/api/auth/me -i shows 401, www-authenticate: Bearer and "error":"Could not validate credentials"; the same with no header at all shows the same three things; the same with a token whose header says alg: HS256 shows the same three things.
  8. (Skip on a new service until it has an API-key door; the reading is then the same.) Mint a key with scopes ["read"] through POST /api/developer/keys: GET /api/forms/templates with X-API-Key is 200; POST to the same path is 403 "API key scope does not permit POST on this resource."; GET /api/prima/v1/models with the same key is 403 naming the ai scope. Mint a key with ["ai"]: the gateway call is 200 and GET /api/forms/templates is 403 "This API key is restricted to the ai agent API (/api/prima/v1).".
  9. curl -s http://127.0.0.1:8000/.well-known/prima-jwks.json | python -c "import json,sys; d=json.load(sys.stdin); k=d['keys'][0]; print(k['kty'], k['crv'], k['alg'], 'd' in k)" prints OKP Ed25519 EdDSA False.
  10. Decode any freshly minted access token's payload (python -c "import base64,json,sys; p=sys.argv[1].split('.')[1]; print(sorted(json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4))).keys()))" $T): the keys are ['aud', 'exp', 'iat', 'iss', 'jti', 'sub', 'tid', 'tids_n', 'type', 'ver'] for an ordinary single-workspace user and the same list without tid for a user with zero or several workspaces; aem and dsk appear only for a shared desk account; ver is 2; the header's alg is EdDSA and it carries a kid.
  11. ENVIRONMENT=production JWT_SIGNING_KEY_SOURCE=ephemeral python -c "import core.config" exits non-zero naming the refusal; JWT_SIGNING_KEY_SOURCE=env JWT_ED25519_PRIVATE_KEY= python -c "from core.jwt_keys import get_key_set; get_key_set()" raises KeyConfigurationError containing JWT_ED25519_PRIVATE_KEY is empty.
  12. (Skip on a new service until a gateway exists.) In prima with the service running and no Labs reachable: curl -s -H "Authorization: Bearer $LABS_TOKEN" $PRIMA_BASE/api/prima/v1/whoami -i shows 503, retry-after: 30 and "code":"identity_provider_unavailable"; curl -s -H "X-API-Key: $PRIMA_KEY" $PRIMA_BASE/api/prima/v1/whoami -i shows 200 with "auth_method":"api_key" and "federation_enabled":false.

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.