Cross-language (Python ↔ TypeScript) auth-parity contract test
Context. ADR-047 D2 requires the operations SPA’s TS-side JWKS-local gate (getClaims()) and the apps/api Python FastAPI middleware to agree on the same JWT inputs (signature, expiry, audience, issuer, scope set). Two independent verifier implementations in two languages must reach identical accept / 401 / 403 verdicts, or a token accepted by the UI but rejected by the API (or vice versa) becomes a silent auth split. Landed first at SPEC-432; reused at SPEC-437 and by any future frontend gate (apps/dashboard).
Pattern
A single committed fixture file is the shared source of truth; both languages read it and assert against the same expected-verdict set. Because each side asserts captured == committed_expected, transitively the two sides must agree — without either side importing the other.
-
Deterministic generator (
tests/contracts/fixtures/auth_parity/generate_fixtures.py) mints an ES256 keypair, a JWKS advertising only the legitimate signing key, and the six canonical fixtures:valid-operator,valid-non-operator,expired,signature-invalid,wrong-audience,wrong-issuer. Output is committed JSON ({jwks, audience, issuer, fixtures:[{name, token, expected_verdict}]}) so CI needs no keypair-generation step and the TS test has zero Python dependency.- Non-expired tokens carry a far-future
exp(fixture must not rot); theexpiredcase is pinned to a fixed past timestamp. Identities are fixed UUIDs so the file is byte-stable across regenerations. - signature-invalid is minted by signing with an unpublished intruder key while stamping the legitimate
kidin the header — the verifier resolves the advertised key and the signature check fails. This tests true signature failure, not mere kid-resolution failure.
- Non-expired tokens carry a far-future
-
Shared verdict alphabet. Both gates reduce a token to one of three stable wire-tokens:
"accept"/"401"/"403". 401 = authentication failure (bad signature / expired / wrong aud / wrong iss / malformed / missingsub); 403 = authentic but scope set disjoint from the operations scopes; accept = authentic + carries an operations scope. Identical literals on both sides so the comparison is direct. -
Both tests assert against the committed expected set:
- Python (
apps/api/tests/test_auth_parity_contract.py): feeds each token through the realOperatorAuthGateand through the FastAPI middleware path (TestClient → gated route → HTTP status mapped back to a verdict). The verifier is constructed with the fixture’saudience+issuerpinned — this is load-bearing: an unpinned verifier would not reject wrong-aud/wrong-iss and the two sides would silently diverge. - TS (
apps/operations/src/auth/auth-parity.test.ts, vitest): feeds the same tokens throughgetClaims()overcreateLocalJWKSet(fixture.jwks)with the same pinned audience/issuer.
- Python (
Gotchas
- Pin audience + issuer on the Python verifier.
SupabaseJWKSVerifieronly enforcesaudwhenaudience is not Noneand only enforcesisswhenissuer is not None. If the parity test builds the verifier without them, wrong-aud/wrong-iss tokens returnacceptand the contract passes vacuously while production diverges. The conftest wiresaudience/issuerstraight from the fixture payload. - Algorithm pinning both sides. Python pins
("ES256","RS256")with a__post_init__guard; TS passesalgorithms: ["ES256","RS256"]tojwtVerify. Never letjwtVerifyinfer the algorithm from the token header (alg-confusion / HS256-over-public-key risk). - The fixture set bounds what’s proven. Only the six committed cases are cross-checked; verdicts the fixtures don’t cover (malformed token, missing
sub, empty scopes, HS256) are covered by per-gate unit tests, not the parity contract. Add a fixture if a new divergence class matters.
Related
- apps/api DB-wiring seam:
ConnectionProvider(per-requestAsyncConnection, autocommit-off sorequest_scopeowns the txn) + the operator route openingrequest_scope(conn, operator_id)before invoking anyworlds.application.*handler. First apps/api DB composition; S3/S4/S5 follow the same shape. - The operator-scope gate treats the top-level
scopesarray (ADR-087 D1) as authoritative and checks membership inspectral.core.auth.scopes.OPERATIONS_SCOPES; consistent with ADR-046 D9’sapp_metadata.organization_role == "operations".