Skip to content
GitHub
Integration Issues

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.

  1. 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); the expired case 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 kid in the header — the verifier resolves the advertised key and the signature check fails. This tests true signature failure, not mere kid-resolution failure.
  2. 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 / missing sub); 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.

  3. Both tests assert against the committed expected set:

    • Python (apps/api/tests/test_auth_parity_contract.py): feeds each token through the real OperatorAuthGate and through the FastAPI middleware path (TestClient → gated route → HTTP status mapped back to a verdict). The verifier is constructed with the fixture’s audience+issuer pinned — 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 through getClaims() over createLocalJWKSet(fixture.jwks) with the same pinned audience/issuer.

Gotchas

  • Pin audience + issuer on the Python verifier. SupabaseJWKSVerifier only enforces aud when audience is not None and only enforces iss when issuer is not None. If the parity test builds the verifier without them, wrong-aud/wrong-iss tokens return accept and the contract passes vacuously while production diverges. The conftest wires audience/issuer straight from the fixture payload.
  • Algorithm pinning both sides. Python pins ("ES256","RS256") with a __post_init__ guard; TS passes algorithms: ["ES256","RS256"] to jwtVerify. Never let jwtVerify infer 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.
  • apps/api DB-wiring seam: ConnectionProvider (per-request AsyncConnection, autocommit-off so request_scope owns the txn) + the operator route opening request_scope(conn, operator_id) before invoking any worlds.application.* handler. First apps/api DB composition; S3/S4/S5 follow the same shape.
  • The operator-scope gate treats the top-level scopes array (ADR-087 D1) as authoritative and checks membership in spectral.core.auth.scopes.OPERATIONS_SCOPES; consistent with ADR-046 D9’s app_metadata.organization_role == "operations".