Prima Get an API key

Module kit / Identity and auth

Labs JWT federation door

identity-auth/labs-jwt-federation-door

Kind backend
Domain identity-auth
Maturity scaffold
Reusable extract
Stack fastapi, cryptography, httpx, hmac-sha256
Depends on _shared/auth-dependency-pattern, _shared/hmac-seam-pattern, _shared/error-envelope, _shared/test-layout
Frontends identity-auth/ui/developer-portal-auth-pages
Reference implementation
  • prima/prima/auth/jwt_ed25519.py
  • prima/prima/auth/jwks.py
  • prima/prima/auth/claims.py
  • prima/prima/auth/sessions.py
  • prima/prima/auth/wiring.py
  • prima/prima/seams/labs_internal.py
  • prima/prima/auth/surfaces.py
Measured on prima feature/per-model-pricing cd2c248 2026-09-12
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-12
  • philosophers_stone main 261be0e 2026-09-12
Version 1

Purpose

A person signed in to Labs presents their Labs access token to the standalone Prima gateway and is served as the tenant the token names, without the gateway calling Labs on the request path: the signature is checked offline against a disk-cached JWKS, the subject's grants and workspaces are asked once per session (jti) and cached in prima_sessions, and revocation arrives on a pulled feed instead of a cache expiry. The invariant that makes it trustworthy is that authentication, authorisation and freshness are three channels that cannot be collapsed into one: no module on the API-key path can import the Labs client (tests/auth/test_import_graph.py), so a Labs outage is a login outage for Labs humans and never an outage for a paying key holder.

This recipe is the consumer half, built in prima on 2026-08-03 (7e240c9, the seam's own commit; seams/identity.py line 2 records STATUS: BUILT, 2026-08-03 and line 849 "SEAM 6 is built"), carried through the FIND-011 suspension fixes of 2026-08-08 (c14f552, 9555ded) that status_for_external in step 10 relies on, and measured here at cd2c248 (2026-08-10, the head of feature/per-model-pricing, itself a credits commit), and the contract of the producer half, which Labs has not built: at fd8558f Labs publishes the JWKS and nothing else, so the door answers 503 identity_provider_unavailable to every first request of a Labs session (measured 2026-09-12, Gotchas). Maturity is scaffold for that reason and is not upgraded until a measured-on entry names the Labs commit that ships POST /internal/prima/introspect and GET /internal/prima/revocations.

When to use

Read it before letting any Prima surface (developer.prima.li, the chat server, a desktop client) send a Labs Bearer to api.prima.li, before building the Labs producer endpoints, before adding a second token issuer, and before "simplifying" the session cache into a per-request introspection call. Trigger phrases: "the portal gets 503 on whoami", "identity_provider_unavailable", "Retry-After 30", "why does Prima not just call Labs", "the balance read is blocked", "unknown kid", "feed lag", "which issuer minted this", "can Prima verify a Labs token".

Not this recipe: the API-key door (identity-auth/gateway-api-key-auth, B11: X-API-Key, prima_api_keys, the revenue path), the signing side and the JWKS document itself (identity-auth/ed25519-jwt-and-jwks, B3, and _shared/auth-dependency-pattern S5 for the v2 claim set), the key-sync outbox in the other direction (identity-auth/key-sync-outbox-labs-to-prima, B12), and the per-session key the chat server mints instead of forwarding a Labs token (identity-auth/prima-chat-session-and-gateway-key, B21). Where the tenant comes from once the door is open is _shared/workspace-scoping (S6), whose Prima half is resolve_tenant in this recipe's claims module.

Stack assumptions

Prima at cd2c248: FastAPI with synchronous dependencies on a synchronous SQLAlchemy Session (prima/api/auth.py lines 108 to 141), cryptography for Ed25519 with no JWT library anywhere on the auth path (test_no_auth_module_imports_a_jwt_library, tests/auth/test_import_graph.py line 149; python-jose is not a declared dependency, line 133), httpx imported lazily inside the JWKS fetcher only (prima/auth/jwks.py line 268), daemon threads for the two background loops because the request path is synchronous end to end (jwks.py lines 299 to 325, wiring.py lines 239 to 287). Foundations assumed: S5 for what Labs puts in the token (iss, aud, ver: 2, type, jti, iat, exp, sub, tids_n, tid when tids_n == 1), S10 for the HMAC transport the three authenticated producer endpoints use, S7 for the OpenAI-shaped envelope every refusal here is rendered in.

Configuration read by this door (prima/config.py, names only; the DECLARED list at lines 480 to 540 and the attribute map at 1050 to 1064):

NameRequiredBehaviour
PRIMA_JWT_ISSUERyesthe one iss the registry trusts; compared by set membership, never by pattern (claims.py 133 to 135)
PRIMA_JWKS_URLyesLabs' /.well-known/prima-jwks.json; must be https:// outside PRIMA_ENV=dev (config 1010 to 1020)
PRIMA_JWKS_CACHE_PATHyesthe disk copy, mode 600, loaded before the network at boot; "not a temp file and must survive a restart"
PRIMA_JWKS_REFRESH_SECONDSno, default 300, bounds 30 to 86400background refresh interval; a failed refresh keeps the last set indefinitely
PRIMA_LABS_INTERNAL_BASE_URLyeswhere the three authenticated producer endpoints would live; nothing calls it at cd2c248
PRIMA_LABS_INTERNAL_SECRETyesthe HMAC key for those calls; boot refuses when it equals PRIMA_KEY_SYNC_SECRET or PRIMA_CREDIT_GRANT_SECRET (config 946 to 990), because this one is read-only and the other two can mint a key or money
PRIMA_PUBLIC_BASE_URLyesself_issuer on identities resolved through the key door (wiring.py line 213); the empty second issuer seat

Fail behaviour: a missing required key refuses boot (ConfigError); a missing or corrupt JWKS cache is not fatal and means no Labs JWT can be verified until the first successful refresh, while API-key traffic is unaffected (jwks.py 189 to 223).

Data model

Prima's identity tables (prima/auth/models.py, migration migrations/versions/20260803_0001_auth_seam.py, revision prima_auth_001, revises prima_credits_0002), the two this door writes:

-- the once-per-jti introspection cache, durable on purpose (models.py lines 255 to 288)
prima_sessions (
  jti             varchar(64) PRIMARY KEY,           -- CHECK: a bounded token, prima/content_free.py token_check
  subject_id      uuid NOT NULL REFERENCES prima_subjects(id) ON DELETE RESTRICT,
  tenant_id       uuid NULL REFERENCES prima_tenants(id) ON DELETE RESTRICT,   -- written NULL at cd2c248
  surfaces        varchar(64)[] NOT NULL,            -- the introspected grants
  workspaces      uuid[] NOT NULL,                   -- the introspected memberships
  exp             timestamptz NOT NULL,
  introspected_at timestamptz NOT NULL,
  revoked_at      timestamptz NULL                   -- a tombstone; set once, never cleared
);
CREATE INDEX ix_prima_sessions_subject ON prima_sessions(subject_id);
CREATE INDEX ix_prima_sessions_exp     ON prima_sessions(exp);       -- purge_expired is a range scan

-- where the feed poller got to: one row by primary key (models.py 291 to 307)
prima_revocation_cursor (
  singleton  boolean PRIMARY KEY DEFAULT true CHECK (singleton),    -- ck_prima_revocation_cursor_singleton
  cursor     bigint NOT NULL DEFAULT 0,                              -- a sequence, never a timestamp
  fetched_at timestamptz NOT NULL
);

prima_subjects (one row per federated or native caller, UNIQUE (issuer, external_sub), status defaulting to 'active', CHECK (is_native OR email IS NULL) so a federated subject can never carry an address; lines 142 to 160) is read by this door for suspension (FIND-011) and is owned by B11. Every ON DELETE on these tables is RESTRICT; there is no cascade in the identity half.

In-process state (prima/api/auth.py AuthRuntime, lines 30 to 44, built once per process): the JwksStore (a dict kid -> Ed25519PublicKey, a fetched_at, and a global unknown-kid cooldown, jwks.py 117 to 137), the IssuerRegistry with exactly one Issuer(iss, jwks, native=False, realm="labs") (wiring.py 177 to 181), and the FeedState(started_at, last_success_at, cursor, consecutive_failures) (sessions.py 109 to 126). The disk cache is a JSON envelope {"fetched_at": <epoch float>, "document": <the JWKS as fetched>} written by atomic replace (_persist, 225 to 250).

What is NOT stored: the raw token (introspection sends it to Labs once and nothing writes it), any email, username, role or company (SubjectFacts and Identity are PII-free by construction, sessions.py 143 to 158, seams/identity.py 284 to 313), a per-kid refresh budget (global on purpose, jwks.py 27 to 29), and a TTL on key material (there is none; jwks.py lines 3 to 14).

What the producer must store and Labs does not have at fd8558f: a durable prima_revocation_events table, written in the same transaction as the logout, password reset, deactivation, grant revocation or membership removal that caused it, with a bigint sequence as the cursor (seams/labs_internal.py 95 to 124). The recipe's shape for it:

prima_revocation_events (
  seq        bigserial PRIMARY KEY,                  -- the cursor; timestamps tie at a page boundary
  kind       varchar(40) NOT NULL CHECK (kind IN ('jti_revoked', 'subject_deactivated', 'grant_revoked', 'workspace_membership_revoked')),
  payload    jsonb NOT NULL,                         -- {jti, exp} | {sub} | {sub, surface} | {sub, tid}
  created_at timestamptz NOT NULL DEFAULT now()
);

Removals only. subject_reactivated and grant_granted are deliberately absent: a re-grant is picked up at the next introspection, and a feed that can grant is a feed that can be replayed to grant (sessions.py 415 to 425).

API surface

The consumer (Prima, cd2c248), all under /api/prima/v1, OpenAI-shaped envelope per S7:

RouteAuth depTenancyCodesNotes
any gateway routeCurrentIdentity (prima/api/auth.py 144)resolve_tenant from tid, tids_n and X-Workspace-ID (claims.py 195 to 252)Surfaced to the caller: 200; 401 invalid_api_key (the uniform authentication error, prima/errors.py 108 to 111, for every refusal raised before the subject is known); 401 credential_revoked; 401 token_expired only from a cached session row (sessions.py 303 to 306); 403 surface_not_granted; 400 workspace_invalid, workspace_ambiguous; 403 workspace_forbidden; 409 no_workspace; 503 identity_provider_unavailable with Retry-After: 30. Internal, logged and never on the wire: token_expired and token_not_yet_valid and token_lifetime_excessive from the claim floor, token_type_invalid, token_version_unsupported, iss_untrusted, claim_*_invalid, claim_tid_unexpected (claims.py 125 to 192), and the first-pass audience codes aud_missing, aud_ambiguous, aud_unknown, aud_not_issuable, insufficient_surface (surfaces.py 93 to 136)every one of the internal codes is raised before the subject is known and converted to _Undecodable (seams/identity.py 699 to 732: except ClaimError at 712, except PrimaError at 729), so the caller sees either the key-door answer, when an X-API-Key or a key-shaped Bearer is present, or authentication_error() (resolve, 578 to 618); a Bearer that does not decode falls through to X-API-Key (S5)
GET /api/prima/v1/whoamiCurrentIdentityas above200subject_id, tenant_id, issuer, auth_method, aud, scopes, surfaces, cached_until, degraded, and identity_provider: {federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state} (auth.py 154 to 184); no PII, so it can be pasted into a support conversation

The producer (Labs; the contract from prima/seams/labs_internal.py lines 65 to 152, exactly four reads, one direction). Status at fd8558f in the last column:

RouteAuthResponseBuilt
GET /.well-known/prima-jwks.jsonnone, public, Cache-Control: public, max-age=120{"keys": [{"kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "kid": <thumbprint>, "x": <base64url>}]}yes: backend/api/routers/jwks_router.py, _MAX_AGE_SECONDS = 120 (line 52), mounted before the catch-all (B3)
POST /internal/prima/introspectS10's canonical string ts\nnonce\nMETHOD\npath\nsha256hex(body) and headers X-Prima-Timestamp, X-Prima-Nonce, X-Prima-Signature: v1=<hex>, signed by internal_auth_headers (wiring.py 97 to 128). Window and replay guard: the design text in labs_internal.py lines 45 to 48 and the signer's docstring (wiring.py 114 to 115) ask for 60 s skew and a 120 s Redis nonce cache; every built S10 verifier uses 300 s and SQL idempotency with no nonce cache (_shared/hmac-seam-pattern.md Data model and verifier step 3; S10 Gotchas dated 2026-09-11 record the 60 s figure as a design that never shipped). The producer implements S10 as built, 300 s and no cache, so the shared HMAC vector in Tests is the S10 vector; a re-verified token is the only replay guard introspection needs, because a replayed introspection reads and writes nothingbody {"token": <the JWT as presented>, "token_type_hint": "access_token"}; active: exactly the ten keys of INTROSPECTION_KEYS (lines 162 to 165), typed as the consumer parses them (IntrospectionResult 171 to 182, IntrospectionAnswer sessions.py 203 to 216): active bool, the presented token is live and unrevoked; iss string, the issuer, must equal the token's; sub string, the subject UUID as text, must equal the token's; aud one string, never a list; jti string, must equal the token's; exp integer epoch seconds, becomes the session row's exp; is_active bool, the subject is not deactivated; surfaces array of surface names from the registry vocabulary (surfaces.py 64 to 86); workspaces array of workspace UUID strings, an unparseable one dropped by _uuids (391 to 401); checked_at integer epoch seconds. Inactive: {"active": false} and nothing elseno (`grep -rln introspect backend/
GET /internal/prima/revocations?since=<cursor>&limit=1000HMAC as above{"cursor": "<seq>", "has_more": bool, "as_of": <integer epoch seconds>, "events": [{"seq", "kind", ...payload}]}; labs_internal.py 95 to 112 leaves as_of untyped ("as_of":...), the recipe fixes it as integer epoch seconds to match exp and checked_at; seq integer, kind one of the four strings under Data model, the payload keys per kindno
POST /internal/prima/keys/lookupHMAC as above{"found": true, "sub", "tid", "scopes": ["ai"], "is_active", "expires_at"} or {"found": false}; cutover only, gated by PRIMA_LABS_KEY_LOOKUP_UNTIL where unset means disabledno, and deliberately not wired on the consumer either (seams/identity.py 630 to 637; the variable is absent from prima/config.py on purpose)

The producer must re-verify the presented token before answering, so it only answers about tokens the caller demonstrably holds and a compromised Prima cannot enumerate subjects (lines 80 to 82); {"active": false} carries no reason and no timing signal (RFC 7662 section 2.2). These endpoints bind to the internal interface and are blocked by the ingress on every public host (lines 55 to 56).

Application logic

One Labs Bearer on the gateway (GatewayIdentityProvider._resolve_jwt, prima/seams/identity.py 691 to 808; everything before the subject is known raises _Undecodable and falls through):

  1. peek_kid(token) (jwt_ed25519.py 138): the unverified header's kid, bounded by MAX_TOKEN_BYTES = 8192 (line 82). No kid is undecodable.
  2. _key_for(kid) (810 to 826): the registry's stores are asked in sorted issuer order; a miss denies this one token and, at most once per UNKNOWN_KID_REFRESH_INTERVAL_S = 300 across every kid, logs jwks_unknown_kid (should_refresh_for_unknown_kid, jwks.py 166 to 173, stamps the cooldown and returns a bool; the caller at identity.py 821 to 824 logs on True and nothing else). The out-of-band refresh the comment at 817 to 820 and the jwks.py docstring describe is not performed at cd2c248: nothing signals the refresh thread and nothing calls refresh_once off the request path (Gotchas, 2026-09-12). The key set changes only when the background loop's next scheduled refresh_once lands. key_for never fetches (141 to 144).
  3. verify(token, public_key) (jwt_ed25519.py 190): alg must equal EdDSA, no dispatch table; a bad signature is undecodable.
  4. parse_claims(payload, issuers, now) (claims.py 125 to 192): iss in the allowlist, type == "access", ver >= 2, sub a UUID, jti at most 64 characters, exp > now - 60, iat <= now + 60, nbf honoured when present, exp - iat <= 86400, tids_n >= 0, tid present exactly when tids_n == 1 (a tid with any other tids_n is claim_tid_unexpected: the issuer resolved an ambiguity it was told not to resolve). Every refusal is a ClaimError and, at this step, a fallthrough.
  5. The key that verified must belong to the issuer the token names (719 to 727): with one issuer unreachable, with two the exact confusion iss exists to prevent.
  6. enforce_audience(aud, allowed, granted=None) first pass (surfaces.py 93 to 136): a real surface, issuable (never primapvs), permitted on this route (GATEWAY_SURFACES is every issuable surface; MANAGEMENT_SURFACES is {prima_dev} for key management). The grant half waits for step 7.
  7. Federation.facts_for(raw_token, jti, subject_id, issuer, native=False) (sessions.py 267 to 388), the one step that may cost a Labs call, in order: feed_health at DENY (lag at or past DENY_LABS_AFTER_S = 3600) refuses every Labs token with 503; a cached prima_sessions row answers locally (revoked is 401 credential_revoked, expired is 401 token_expired, otherwise the facts, marked degraded when the feed is DEGRADED, lag at or past DEGRADED_AFTER_S = 900); an unseen jti with a stale feed or with introspect is None is 503 identity_provider_unavailable with Retry-After: 30; else introspect once, and an answer with active false or is_active false (sessions.py line 344: if not answer.active or not answer.is_active, so a live token whose subject is deactivated is tombstoned the same way as {"active": false}) writes a tombstone row (revoked_at = now, exp = now) so Labs is never asked about that jti again; an answer whose jti, sub or iss disagrees with the token is denied as revoked (identity_introspection_mismatch); a good answer is written as the session row with exp from the answer and returned with degraded=False.
  8. enforce_audience(aud, allowed, granted=facts.surfaces) second pass: the claim said which door, the introspected grants say whether the subject holds it; surface_not_granted is 403, exactly where Labs' gate fails closed.
  9. resolve_tenant(claims, workspace_header, workspaces=facts.workspaces): tids_n == 1 uses tid and refuses a mismatching header with 403; tids_n > 1 requires the header and checks it against the introspected set; tids_n == 0 is 409. Zero Labs calls in the common case.
  10. Suspension, read fresh on this request through status_for_external(iss, sub) (repository.py line 97), by the Labs id and not the local one (the comment at 754 to 771 says why a lookup by id would silently admit every suspended Labs subject).
  11. authorize_gateway(Identity(...)) (459 onward): the single gate both doors funnel through; the identity carries auth_method="jwt", scopes=frozenset(), surfaces=facts.surfaces, cached_until=exp, aud, degraded, subject_active.

Off the request path, per process (prima/api/auth.py init_auth, 53 to 69, called from the app lifespan):

  1. build_jwks_store: load_from_disk() before any network (wiring.py 169 to 174; event jwks_startup with jwks_age_seconds).
  2. start_identity_background(settings, jwks, feed, poll=None): the JWKS refresh thread runs refresh_once every PRIMA_JWKS_REFRESH_SECONDS; every successful fetch is installed and persisted, every failure keeps the last set (jwks.py 279 to 296). With poll=None the revocation poller is not started and the warning identity_federation_disabled reason=phase_a_not_shipped is logged once at boot (wiring.py 261 to 265). With a poller, poll_revocations_once (290 to 324) pulls client.revocations(feed.cursor) every REVOCATION_POLL_INTERVAL_S = 5, applies the batch through apply_revocations (sessions.py 409 to 446: four kinds map to four UPDATE ... SET revoked_at WHERE revoked_at IS NULL shapes in SqlSessionStore, repository.py 181 to 206; an unknown kind is skipped and logged, a malformed payload is logged and skipped), and advances the cursor only after a successful apply; a transport failure increments consecutive_failures and is a lag increase, never an exception.

Invariants

  • The JWKS fetch can never fail a request: disk is loaded before the network, a refresh failure keeps the last set with no TTL, and key_for performs no I/O (test_a_refresh_failure_never_expires_the_key_set, test_key_for_never_fetches, test_disk_is_loaded_before_the_network_is_ever_touched, tests/auth/test_jwks.py lines 55, 148, 40).
  • An unknown kid denies that one token, never fetches inline, and is granted a refresh budget at most once per 300 s across all kid values (test_the_rate_limit_is_global_not_per_kid, line 128; test_an_unknown_kid_denies_only_that_token, test_labs_down.py line 308). At cd2c248 the granted budget produces one jwks_unknown_kid log line and no fetch; the key set is refreshed by the 300 s background loop alone (Gotchas). The invariant a rebuild must add, and test, is that the granted budget triggers exactly one refresh_once off the request path.
  • Labs is asked at most once per jti, on the first request of a session, and never on the API-key path (test_a_first_request_introspects_once_and_never_again, test_resolver.py line 214; test_an_unknown_key_is_401_and_does_not_read_through_to_labs, line 92).
  • No module on the key path can reach prima.seams.labs_internal, exactly one module imports it, and that module really holds the edge (test_import_graph.py lines 87, 100, 115).
  • A revocation is terminal: put on a tombstoned jti returns without writing (repository.py 150 to 154); applying the same event twice affects zero rows; a tombstone is kept until the token it denies has expired (purge_expired, 208 to 216).
  • Ambiguity denies and never synthesises a grant: a mismatched introspection answer, a native jti with no row, a list-valued aud, a tid with tids_n != 1 (test_ambiguity_always_denies_and_never_synthesises_a_grant, test_labs_down.py line 324).
  • The feed only ever removes access; the four kinds are the whole vocabulary and a fifth is ignored, not fatal.
  • A cold start is not an outage: lag is measured from started_at until the first successful poll (test_a_cold_start_is_not_a_sixty_minute_outage, line 280).
  • The two ceilings are the numbers they were argued from: 900 s equals Labs' _REVOCATION_ACCESS_TTL_MINUTES and 3600 s equals Labs' access-token lifetime, so "deny everything Labs-issued" never outlives a token that would have expired anyway (test_the_ceilings_match_the_numbers_they_were_argued_from, line 212).
  • The producer's revocation write raises inside the causing transaction; a best-effort try/except there means revoked sessions served with no symptom (labs_internal.py 120 to 124).
  • The producer's introspection answer has exactly ten keys, asserted by equality and not containment, so the seam cannot start carrying PII unnoticed (lines 162 to 168).

Frontend

The modules that render this door, by id:

identity-auth/ui/developer-portal-auth-pages     F7, interim: the portal holds the prima_dev Bearer that hits this door;
                                                 a Labs Bearer sent to the standalone gateway at cd2c248 gets the 503
                                                 until the producer ships (the scaffold state, Gotchas; portal 4428767
                                                 itself recorded /usage answering 200 with a session Bearer, measured
                                                 2026-08-03 against the in-Labs gateway, src/lib/prima-api.ts 630)
billing-credits/ui/developer-portal-konto        F27, unmerged (W6): the balance read is the first customer-visible
                                                 consumer; blocked by this row's 503 until 2026-09-11, when decision
                                                 A-2 (a) had the portal mint a per-session ai key at sign-in and send
                                                 it as X-API-Key to GET /api/prima/v1/usage (w6-konto c594dfc,
                                                 src/lib/gateway-key.ts 1 to 12, src/lib/billing.ts 857 to 861: the
                                                 identity_provider_unavailable state is removed there), mirroring B21;
                                                 a Labs Bearer still cannot be used directly; linked once the stub exists
billing-credits/ui/billing-outbox-admin-console  F30, planned: the admin table for every outbox, where the revocation
                                                 feed's cursor and lag belong

Minimum admin and debug surface: GET /api/prima/v1/whoami (which door, which tenant, federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state); the structured events by name, jwks_startup, jwks_loaded_from_disk, jwks_cache_unusable (with check one of oversize, corrupt, no_keys; jwks.py 201, 208, 213), jwks_entries_rejected (111), jwks_installed, jwks_refresh_failed, jwks_refresh_rejected, jwks_unknown_kid, jwks_cache_write_failed, identity_federation_disabled, identity_new_session_refused, identity_feed_deny, identity_introspection_failed, identity_introspection_mismatch, identity_resolved (with subject_realm, kid, degraded), revocation_poll_failed, revocations_applied, revocation_event_unknown, revocation_event_malformed, revocation_cursor_rewind_refused (prima.logging.event, bounded tokens and integers only); and SELECT count(*) FILTER (WHERE revoked_at IS NOT NULL) FROM prima_sessions as the tombstone count.

Security & tenancy

Auth dependency: current_identity (prima/api/auth.py 108 to 141) reads Authorization, X-API-Key and X-Workspace-ID as typed headers, builds the provider per request against the request's session, and passes introspector=None at cd2c248. There is no cookie branch and there will not be one (_bearer, seams/identity.py 828 to 836; test_there_is_no_cookie_branch, test_resolver.py line 189): a Labs cookie is set on the Labs origin and never reaches api.prima.li.

Workspace derivation: tid when tids_n == 1, the header checked against the introspected workspaces[] when tids_n > 1, 409 when zero; never a server-side default (S6, and the docstring at seams/identity.py 123 to 137: a default "silently reattributes a multi-workspace customer's spend").

Fail direction, per seam: signature, claim floor, issuer, audience closed; unknown kid closed for that token, open for everyone else; introspection transport failure closed for the new session with 503, open for cached sessions; feed lag past 900 s closed for new sessions, past 3600 s closed for every iss=labs token, open for keys and native tokens throughout (the asymmetry is the design); an unparseable workspace id in the introspection answer is dropped, not fatal, because it grants nothing and raising would let one bad row lock a person out (_uuids, sessions.py 391 to 401); JWKS persistence failure open (in-memory set is already good, jwks.py 225 to 231).

Credentials at rest: Prima holds no material that can mint a Labs token, which is why the internal seam is HMAC and not a bearer JWT (labs_internal.py 50 to 53); the JWKS cache holds public keys only, mode 600; PRIMA_LABS_INTERNAL_SECRET is pairwise distinct from the two write-direction keys or boot refuses.

Rate limits: the unknown-kid budget (one per 300 s, global; at cd2c248 it bounds the jwks_unknown_kid log line, not a fetch, because no fetch is wired to it, Gotchas); the JWKS refresh loop (one fetch per PRIMA_JWKS_REFRESH_SECONDS, the only fetch there is); the feed poll (one per 5 s); the JWKS document bounded at MAX_DOCUMENT_BYTES = 65536 and MAX_KEYS = 20; no per-client limiter on the door itself (the gateway's limiter is B18's).

Attacker-controlled inputs: the JOSE header (kid looked up in a dict, at most 64 characters when it came from the JWKS, and never scanned or fetched inline); the token bytes (8192 cap before base64); every claim (typed, bounded, floors and ceilings above); the JWKS response (size, count, per-entry parse with one bad entry rejected and a document with no usable entry refused, jwks.py 82 to 114); the introspection answer (must agree with the token; ten keys); every feed event (unknown kind skipped, malformed payload skipped); the X-Workspace-ID header (a UUID compared against a set).

Tests

Prima (tests/auth/, run 2026-09-12 at cd2c248 with PYTHONPATH=. python -m pytest tests/auth -q -o addopts=""): 232 passed, 21 skipped in 5.96s; the skips are test_repository_live.py, which needs the database. The door's own files, run together the same day: test_labs_down.py, test_import_graph.py, test_jwks.py, test_resolver.py, test_claims_and_surfaces.py print 104 passed.

  • tests/auth/test_labs_down.py: the degradation table row by row. test_a_paying_customer_is_served_no_matter_how_long_labs_has_been_down (line 101, parametrised over lag), test_the_key_path_is_served_with_no_jwks_at_all (117), test_a_cached_labs_session_is_served_while_labs_is_down (149), test_a_new_labs_session_is_refused_with_retry_after (167: code identity_provider_unavailable, Retry-After equal to "30"), test_with_federation_not_yet_shipped_new_labs_sessions_are_refused (181: introspector=None, the live configuration), test_feed_health_thresholds (202), test_degraded_serves_cached_sessions_and_marks_them (225), test_degraded_refuses_a_new_session_even_when_labs_answers (242), test_beyond_the_hard_ceiling_every_labs_token_is_denied_but_keys_are_not (259), test_jwks_serves_from_disk_indefinitely_while_labs_is_down (297).
  • tests/auth/test_resolver.py: the once-per-jti rule (214), the tombstone (244), the mismatch denial (259), the revoked session answered without asking Labs (277), the cached session without the grant as 403 and not a fallthrough (292), the four fallthrough cases (128, 146, 156, 173).
  • tests/auth/test_jwks.py: 16 cases on the store; known answers include the persisted envelope being JSON with a fetch time (171) and the cache file not being world-readable (35).
  • tests/auth/test_claims_and_surfaces.py: 28 cases on the claim floor and the audience rules; the tenant table (149 to 198, eight cases from test_single_workspace_no_header_costs_zero_labs_calls to test_a_malformed_workspace_header_is_400) is S6's known-answer set; 116 to 139 are the tid and required-claim floor cases.
  • tests/auth/test_repository_live.py (database required, 21 cases): test_session_round_trip (464), test_revoking_a_grant_kills_only_sessions_on_that_surface (474), test_revoking_membership_uses_the_workspace_array (487), test_a_revocation_is_terminal_and_reintrospection_cannot_undo_it (502), test_applying_the_same_revocation_twice_is_free (514), test_a_denied_session_is_denied_by_the_resolver_after_the_feed_applies_it (525), test_the_cursor_is_monotonic (549).
  • Known-answer vector for the signature primitive: the seed, kid, payload and 466-character compact token recorded in S5 Tests (computed 2026-09-12 against core/jwt_ed25519.py at fd8558f and verified with prima/auth/jwt_ed25519.py::verify at cd2c248). A rebuilt verifier must accept it byte for byte; a rebuilt signer must produce it.

What the producer adds when it is built (the twin files, per S10's rule that signer and verifier live in different repos pinned by one shared known-answer test): a Labs test that POST /internal/prima/introspect answers exactly INTROSPECTION_KEYS for an active token and {"active": false} for anything else; a Labs test that every revocation-causing path writes a prima_revocation_events row in the same transaction and that a failed write raises; a Prima test that a canned feed page from Labs' serialiser applies through apply_revocations with the expected affected count; one HMAC vector (timestamp, nonce, method, path, body hash, signature hex) shared by both repos.

Hermetic versus live: everything above is hermetic except the 21 live repository tests; nothing reaches a deployed host. Playwright gate: none; the door has no page of its own (F7's spec covers the portal's sign-in, F27's will cover the balance read).

Deploy & ops

Config keys per app heredoc (S13): on Prima PRIMA_JWT_ISSUER byte-equal to Labs' JWT_ISSUER, PRIMA_JWKS_URL pointing at the API host's /.well-known/prima-jwks.json and not the SPA host (S5 Deploy & ops records the trap), PRIMA_JWKS_CACHE_PATH on a volume that survives a container replace, PRIMA_JWKS_REFRESH_SECONDS left at 300, PRIMA_LABS_INTERNAL_BASE_URL and PRIMA_LABS_INTERNAL_SECRET set even though nothing calls them yet (boot requires both); on Labs the same HMAC key under a name of its own once the producer exists, and JWT_ED25519_PUBLISHED_PUBLIC_KEYS for rotation (B3).

Rotation contract on the producer: publish the new public key at least one full PRIMA_JWKS_REFRESH_SECONDS before signing with it (jwks.py 31 to 36). At cd2c248 the loop interval is the whole bound: the docstring's out-of-band refresh on an unknown kid is not wired (Gotchas, 2026-09-12), so a key that signs before the next scheduled refresh fails every token it signs, with the uniform 401, for up to PRIMA_JWKS_REFRESH_SECONDS, and the operator sees one jwks_unknown_kid line per 300 s while it lasts. Once the refresh is wired the out-of-band fetch shortens that window to one request plus one fetch, and the interval becomes the ceiling rather than the rule. Labs' max-age=120 is under the 300 s refresh on purpose (B3).

Migrations order: prima_auth_001 after prima_credits_0002 on Prima's own chain (S8's prima_alembic_version); on Labs the prima_revocation_events table lands with the producer, before any code writes it.

Scheduler jobs: two daemon threads per Prima process (prima-jwks-refresh, prima-revocation-poll when a poller is passed), one shutdown callable returned to the lifespan (wiring.py 283 to 287). Under --workers N each process refreshes and polls on its own; the feed apply is idempotent so N pollers are wasteful and harmless. No housekeeping job calls purge_expired at cd2c248 (Gotchas).

Argus checks: whoami with a known key returns 200 and identity_provider.jwks_keys >= 1; jwks_age_seconds under 900 on a host where Labs is up (a value climbing past that is the refresh loop failing, not a request failure); once the poller exists, revocation_lag_seconds under 30 and feed_state == "fresh". Today, with poll=None, feed_state is a function of process uptime and not of Labs: fresh for the first 900 s after boot, degraded until 3600 s, deny from then on, and revocation_lag_seconds equals the uptime (Gotchas, 2026-09-12); an alert on either is meaningless until the poller is wired. Rollback: configuration only; a Prima rolled back to before prima_auth_001 cannot serve any Labs JWT, which is today's state.

Gotchas

  • 2026-09-12 (measured at cd2c248 and fd8558f): the door is a scaffold. current_identity passes introspector=None (prima/api/auth.py line 130) and init_auth passes poll=None (line 65), so every first request of a Labs session is 503 identity_provider_unavailable with Retry-After: 30, cached sessions are never written, and whoami hard-codes federation_enabled: false (line 178). On Labs, grep -rln introspect backend/ | grep -v tests hits only services/primalayered, and no route under /internal/prima/ exists. Only the API-key door is complete end to end. F27's balance read was blocked on this row until 2026-09-11; at w6-konto c594dfc the portal mints a per-session ai key at sign-in and reads /usage with X-API-Key (src/lib/gateway-key.ts 1 to 12, decision A-2 (a); src/lib/billing.ts 857 to 861 removes the identity_provider_unavailable state because the portal no longer sends a Bearer there). The 503 remains the reason a Labs Bearer cannot be used directly by any Prima surface.
  • 2026-09-12 (measured at cd2c248): the out-of-band JWKS refresh on an unknown kid is described and not built. jwks.py lines 23 to 25 and 35 to 36, the comment at seams/identity.py 817 to 820 and the design table at 891 to 893 all say a miss "asks for" or "triggers ONE out-of-band refresh"; what the code does is should_refresh_for_unknown_kid() stamping _last_unknown_kid_refresh and returning True once per 300 s (jwks.py 166 to 173), and _key_for logging jwks_unknown_kid on True and returning (None, None) (821 to 825). refresh_once is called from the background loop only (start_background_refresh, 299 to 325, stop.wait(interval_s) with no wake event); grep -rn "refresh_once\|should_refresh_for_unknown_kid" prima/ shows no other caller, and no Event, queue or fetcher reaches GatewayIdentityProvider or IssuerRegistry (identity.py 334 to 388, 534 to 566). The tests that touch it (test_jwks.py 119 to 125 and 128 to 147, test_labs_down.py 308) assert the budget arithmetic and the denial, never a fetch. Effect: the only bound on a too-early rotation is the 300 s loop. The fix, when code changes are in scope: give the refresh thread a threading.Event that _key_for sets when the budget is granted, with the loop waiting on that event with interval_s as its timeout instead of stop.wait(interval_s) (or a request_refresh() on JwksStore the loop honours), so the granted budget triggers one refresh_once off the request path and never inline; plus a test that an unknown kid causes exactly one fetch within the cooldown. Until then this recipe's Application logic, Invariants, Rate limits and rotation contract describe the log line, not a fetch.
  • 2026-09-12 (measured at cd2c248): the scaffold's feed lag climbs from boot. With poll=None nothing ever sets FeedState.last_success_at, so lag_seconds measures from started_at (sessions.py 124 to 126; prima/api/auth.py 60 to 65 builds FeedState(started_at=now)), and on a healthy key-serving process whoami.identity_provider.feed_state reads degraded after 900 s of uptime and deny after 3600 s (feed_health, 129 to 135; whoami 181 to 182). A Labs Bearer presented after the first hour takes the identity_feed_deny ERROR branch (facts_for, 284 to 295) instead of the identity_new_session_refused WARNING branch (327 to 334); the answer is the same 503 with Retry-After: 30, so check 6 holds either way, but the log carries an ERROR per Labs Bearer for the life of the process. When the poller is wired the first successful poll resets the baseline; until then read feed_state as uptime.
  • 2026-09-12 (measured at cd2c248): the module docstrings still describe Labs as signing HS256 and say none of the four endpoints exists (prima/auth/wiring.py lines 9 to 34, prima/seams/labs_internal.py lines 50 to 53 and 204 to 205, prima/auth/claims.py lines 34 to 36). Labs has signed Ed25519 with a kid and published /.well-known/prima-jwks.json since ca227b9 (2026-08-03, B3). One of the four exists; the argument for HMAC over a bearer JWT on the internal seam still holds for a different reason (Prima should hold nothing that mints a Labs token, and an HMAC key scoped to four reads cannot), so the transport does not change.
  • 2026-09-12 (measured at cd2c248): wiring.py line 25, the degradation table at seams/identity.py 866 to 867 and the test_labs_down.py header table (lines 17 to 18) all say the unavailable-provider answer is 401; _unavailable() (sessions.py 223 to 231) builds a 503, and test_a_new_labs_session_is_refused_with_retry_after asserts the code and the header, not the status. The code wins: 503 with Retry-After: 30.
  • 2026-09-12 (measured at cd2c248): SqlCursorStore (repository.py 219 to 245, read at boot and written per poll, monotonic) exists and is exercised by test_the_cursor_is_monotonic, but poll_revocations_once reads and writes feed.cursor on the in-memory FeedState and nothing constructs a SqlCursorStore outside that test (grep -rn SqlCursorStore prima/ hits the definition and its import only). When the poller is wired, FeedState.cursor must be seeded from SqlCursorStore.read() at boot and write() called after each successful apply, or a restart replays the feed from zero (harmless but slow) and a long outage loses nothing only because the cursor never advanced in the first place.
  • 2026-09-12 (measured at cd2c248): purge_expired (repository.py 208 to 216) has no caller; prima_sessions grows by one row per Labs session for the life of the table. Wire it as a daily job under S12 once sessions are being written; the index on exp is already there.
  • 2026-09-12 (measured at cd2c248): IssuerRegistry's docstring (seams/identity.py 367 to 368) says tests/auth/test_issuer_registry.py asserts the issuer count; no such file exists (ls tests/auth). The single-issuer fact is pinned indirectly by build_issuer_registry registering one and by the tests that construct a registry by hand. A second issuer is still a decision, not an accident, but the guard the docstring names is not there.
  • 2026-09-12 (measured at cd2c248): prima_sessions.tenant_id is declared and written NULL on every path (sessions.py 351 and 380); the tenant is resolved from the claims per request and never cached. The column is reserved, not used.
  • 2026-09-12 (measured at fd8558f): the revocation feed presupposes revoke_jti writing durable state; today it writes Redis only when JWT_DENYLIST_ENABLED is on, which it is not on the estate, and fails open on a Redis error (S5 Gotchas). Building the producer's prima_revocation_events write is an improvement to Labs' own posture independent of this door, and the inventory (row B13) and labs_internal.py 114 to 124 both say so.
  • 2026-09-12 (wiki 261be0e, platform/primaapi.md "Open items"): the page still says Labs' half of the seam does not exist "in any branch" and lists the JWKS among the missing pieces; the JWKS is built (B3). The other two pieces are missing as the page says. ADR-0030 (Infomaniak OIDC as the estate's identity provider, status "proposed" as of 2026-08-08) would add a third trust root to the picture; it does not change this door, because a Labs token stays a Labs token whichever provider signed the person in.
  • 2026-08-03 (cd2c248, recorded in seams/identity.py lines 20 to 39): the design assumed python-jose could verify EdDSA; it cannot, so the verifier is written against cryptography directly and the service has no algorithm-dispatch table. A rebuild that reaches for a JWT library reintroduces the table.
  • 2026-08-03 (cd2c248, seams/identity.py 27 to 30 and jwks.py 27 to 29): the unknown-kid refresh limit was first per-kid; an attacker picks a fresh kid per request and each one gets its own budget. Global or nothing.
  • 2026-08-03 (cd2c248, sessions.py 109 to 117): FeedState without started_at denied every Labs token from boot until the first poll returned, a self-inflicted outage on every deploy.

Reference implementation

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

  • prima (feature/per-model-pricing at cd2c248):
    • prima/auth/jwks.py: MAX_KEYS (69), UNKNOWN_KID_REFRESH_INTERVAL_S (72), MAX_DOCUMENT_BYTES (75), _parse (82 to 114), JwksStore (117 to 250: key_for 141, age_seconds 154, should_refresh_for_unknown_kid 166, install 177, load_from_disk 189, _persist 225), http_fetcher (258 to 276), refresh_once (279 to 296), start_background_refresh (299 to 325).
    • prima/auth/jwt_ed25519.py: ALG (69), MAX_TOKEN_BYTES (82), InvalidToken (85), jwk_to_public_key (112), peek_kid (138), _split (149), verify (190).
    • prima/auth/claims.py: MIN_CLAIM_VERSION (58), CLOCK_SKEW_SECONDS (61), MAX_TOKEN_LIFETIME_SECONDS (66), ACCESS_TOKEN_TYPE (72), Claims (84 to 100), parse_claims (125 to 192), resolve_tenant (195 to 252).
    • prima/auth/sessions.py: DEGRADED_AFTER_S (92), DENY_LABS_AFTER_S (96), RETRY_AFTER_S (100), FeedState (109 to 126), feed_health (129 to 135), SubjectFacts (143 to 158), SessionRecord (161 to 177), SessionStore (180 to 187), Introspector and IntrospectionAnswer (190 to 216), _unavailable (223 to 231), _revoked (234 to 239), Federation.facts_for (267 to 388), _uuids (391 to 401), apply_revocations (409 to 446).
    • prima/auth/wiring.py: REVOCATION_POLL_INTERVAL_S (89), internal_auth_headers (97 to 128), LabsIntrospectorAdapter (136 to 161), build_jwks_store (169), build_issuer_registry (177), build_identity_provider (184 to 215), start_identity_background (239 to 287), poll_revocations_once (290 to 324).
    • prima/seams/labs_internal.py: the four-endpoint contract (65 to 152), INTROSPECTION_KEYS (162), IntrospectionResult (171), RevocationEvent (185), LabsInternal (193 to 218), build_labs_internal raising NotImplementedError (221 to 225).
    • prima/seams/identity.py: Identity (284 to 313), Issuer (334 to 346), IssuerRegistry (349 to 388), _Undecodable (396 to 410), authorize_gateway (459), GatewayIdentityProvider.resolve (567 to 619), _resolve_jwt (691 to 808), _key_for (810 to 826), _bearer (828 to 836), the degradation table (856 to 898).
    • prima/auth/surfaces.py: the vocabulary (64 to 86), enforce_audience (93 to 136).
    • prima/api/auth.py: AuthRuntime (30 to 44), init_auth (53 to 69), current_identity (108 to 141), whoami (154 to 184).
    • prima/auth/models.py: PrimaSession (255 to 288), PrimaRevocationCursor (291 to 307); prima/auth/repository.py: SqlSubjectRepository.status_for_external (97), SqlSessionStore (125 to 216), SqlCursorStore (219 to 245); migrations/versions/20260803_0001_auth_seam.py (prima_sessions 183 to 205, prima_revocation_cursor 208 to 216).
    • Tests as listed under Tests.
  • alchemy_labs (d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6): backend/api/routers/jwks_router.py (the one producer endpoint that exists; _MAX_AGE_SECONDS 52, JWKS_PATH 57); backend/api/routers/prima_inbound_router.py and backend/services/commerce/prima_inbound_sig.py (the nearest existing pattern for an HMAC-verified receiver in Labs, fail-closed, mounted surface_exempt("public", ...) at app_fastapi.py 887); backend/services/auth_service.py revoke_session (856 to 876) and revoke_jti (the sites the prima_revocation_events write attaches to). The introspection and revocation endpoints do not exist; the nearest existing pattern for the verifier they need is prima/api/keys_sync.py::require_labs_signature (cd2c248, lines 209 to 270) mirrored into Labs. The legacy in-Labs gateway copy has its own door for a Labs session, backend/services/primaapi/identity.py::resolve_gateway_identity (194 to 230 at fd8558f: auth_method: "jwt" | "api_key" at 66 to 67, the session decoded in-process by auth_service.decode_token_with_surface, the tenant from the workspace header through _resolve_tenant_for_jwt_user), with no JWKS, no introspection and no feed because it runs inside Labs; it is what api.prima.li served before the standalone gateway, is legacy per D3, and is not this recipe. A reader diagnosing "which door served this Labs session" on a host still routed to Labs is looking at that file.
  • Wiki philosophers_stone (261be0e): platform/primaapi.md ("The auth seam, as built", "Open items"), operations/decisions/0022-prima-extraction.md, operations/decisions/0030-infomaniak-as-identity-provider.md, operations/prima-modules-inventory.md (rows B13, F27, F30; wave 2 note on B13).

Deviations of the reference from this recipe, all dated 2026-09-12: the producer absent (introspect, revocations, prima_revocation_events); introspector=None and poll=None at the composition root; federation_enabled hard-coded false; the unknown-kid refresh budget granted and never spent on a fetch; the feed lag measured from boot until a poller exists; the cursor store and purge_expired unwired; the stale HS256 and 401 docstrings; the missing test_issuer_registry.py; prima_sessions.tenant_id always NULL.

Acceptance checks

Run from the prima repo root at cd2c248 unless stated. Expected observations are exact.

  1. PYTHONPATH=. python -m pytest tests/auth -q -o addopts="" prints 232 passed, 21 skipped; PYTHONPATH=. python -m pytest tests/auth/test_labs_down.py tests/auth/test_import_graph.py tests/auth/test_jwks.py tests/auth/test_resolver.py tests/auth/test_claims_and_surfaces.py -q -o addopts="" prints 104 passed.
  2. grep -rlE "^(from|import) prima\.seams(\.labs_internal| import labs_internal)" prima/ prints exactly one path, prima/auth/wiring.py (the one permitted edge, wiring.py line 66; test_exactly_one_module_imports_labs_internal, test_import_graph.py line 100, pins the same fact). A grep on the bare string labs_internal is not this check: it prints 13 paths at cd2c248, because docstrings, comments and the labs_internal_secret setting name carry the word. grep -n "introspector=None" prima/api/auth.py prints one line, 130, and grep -n "poll=None" prima/api/auth.py prints two, 62 (the comment) and 65 (the call); the scaffold state, and all three lines change when the producer ships, this recipe's maturity with them.
  3. grep -n "DEGRADED_AFTER_S = \|DENY_LABS_AFTER_S = \|RETRY_AFTER_S = " prima/auth/sessions.py prints 15 * 60, 60 * 60 and 30 on lines 92, 96 and 100; grep -n "UNKNOWN_KID_REFRESH_INTERVAL_S = \|MAX_KEYS = " prima/auth/jwks.py prints two lines in file order, 69:MAX_KEYS = 20 then 72:UNKNOWN_KID_REFRESH_INTERVAL_S = 300.
  4. grep -c 'kind == "' prima/auth/sessions.py prints 4 (the four branches of apply_revocations, lines 429 to 437, one per kind), and grep -n "subject_reactivated\|grant_granted" prima/auth/sessions.py prints two lines, 417 and 418, both inside the sentence saying the two kinds are deliberately absent (one name per line; there is no code branch for either). A grep -c on the four kind names is not this check: it prints 5, because the docstring at line 31 mentions is_jti_revoked().
  5. python -c "from prima.seams.labs_internal import INTROSPECTION_KEYS; print(sorted(INTROSPECTION_KEYS))" prints ['active', 'aud', 'checked_at', 'exp', 'is_active', 'iss', 'jti', 'sub', 'surfaces', 'workspaces'], ten names and no email.
  6. With the service running and no Labs reachable: curl -s -i -H "Authorization: Bearer $LABS_TOKEN" $PRIMA_BASE/api/prima/v1/whoami shows HTTP/1.1 503, retry-after: 30 and "code":"identity_provider_unavailable"; curl -s -H "X-API-Key: $PRIMA_KEY" $PRIMA_BASE/api/prima/v1/whoami shows 200, "auth_method":"api_key", "federation_enabled":false and "jwks_keys":1 or more. Both hold at cd2c248 whether Labs is up or not (the scaffold state).
  7. Stop the JWKS host, restart Prima: the boot log carries jwks_startup ok=true with a non-zero jwks_age_seconds, whoami with the key still answers 200, and jwks_keys is unchanged; ls -l $PRIMA_JWKS_CACHE_PATH shows mode -rw-------.
  8. Present a token whose header kid is a fresh random string: the response is 401 with the uniform authentication error ("code":"invalid_api_key"), the log carries exactly one jwks_unknown_kid event, a second such token within 300 s logs none, and no jwks_installed or jwks_refresh_failed event follows either token before the loop's next scheduled tick (the unwired refresh, Gotchas; a rebuild that wires it observes exactly one fetch after the first token and none after the second).
  9. In alchemy_labs/backend at fd8558f: grep -rln "internal/prima" api/ services/ | wc -l prints 0 and curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/.well-known/prima-jwks.json prints 200 (one of four built). When the producer lands, the first grep prints at least 1, and check 6's first call is 200 with "auth_method":"jwt" and "federation_enabled":true.
  10. npm run check in this repo prints no line for this recipe.

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.