CRUMB a card from devarno-cloud

JWT Auth: BetterAuth ES256 via JWKS, the GHA OIDC Machine Issuer, and RBAC

traceo intermediate 7 min read

ELI5

Every request brings a passport, which is a JWT. The web app (BetterAuth) stamps it with a private key, and publishes the matching public key at a well-known address. Both Python services fetch that public key and check the stamp. Nothing shares a password any more. There is one other passport office: GitHub Actions can stamp a passport for a CI job, and Traceo lets that job read one kind of thing and nothing else.

Technical Deep Dive

Source: traceo-cat/web/apps/client/src/lib/auth.ts:184-215, traceo_mcp_server/traceo_mcp_server/auth/jwks_cache.py:22-35, auth/jwt.py:109-111 and :157-270, auth/gha_oidc.py:1-79, auth/middleware.py:68-77 and :354-361, auth/rbac.py:56-72 and :394-445, config.py:57-59, engine/src/engine/auth/jwks_cache.py, engine/src/engine/auth/jwt.py:83-170; decisions 0083, 0094, 0098, 0104, 0109; live GET https://app.traceo.cat/api/auth/jwks; traceo-cat origin/main 7d647ab, read 2026-10-25

Issuer 1: BetterAuth, ES256 via JWKS

The web client’s BetterAuth jwt() plugin signs with keyPairConfig: { alg: "ES256" }, 15-minute tokens (auth.ts:197, :202). It deliberately avoids BetterAuth’s EdDSA default: python-jose cannot build OKP/Ed25519 keys, and under EdDSA every authenticated /api/* call 500’d. The live JWKS on 2026-10-25 published one key, EC P-256 ES256. definePayload re-reads workspaceId and role from the database at mint time, so a workspace switch reaches the next token without waiting out the 5-minute session cookie cache.

Both Python tiers fetch https://app.traceo.cat/api/auth/jwks (TRACEO_JWKS_URL, 5-minute in-process cache) and accept only an asymmetric allowlist: EdDSA, RS256/384/512, ES256/384/512 (jwt.py:109-111). Some docstrings still say “EdDSA”. The deployed algorithm is ES256.

TierSymmetric (HS256) armRole sourceDecision
MCPdeleted; a missing alg no longer falls through to HS256token role claim (jwt.py:260), minted live from the DB0083
Enginedeleted, along with its BETTER_AUTH_SECRET readthe token’s own role claim no longer grants capability0094 (accepted 2026-09-08)

Decision 0094 does not close M-20 on its own; it says so itself.

Issuer 2: GitHub Actions OIDC, a read-only machine

Decision 0098 (accepted) admits https://token.actions.githubusercontent.com as a second issuer. Dispatch is by the unverified iss: a GitHub token never touches BetterAuth’s JWKS, and the reverse holds too.

  • RS256 only, verified against GitHub’s JWKS. The URL is a code constant, not configuration.
  • aud and iss are required. The default audience is https://mcp.traceo.cat (TRACEO_GHA_OIDC_AUDIENCE).
  • The token must match an entry in TRACEO_GHA_OIDC_TRUST exactly, including numeric repository_id / repository_owner_id. Unknown keys invalidate the whole list. An empty or absent list switches the feature off.
  • A match yields principal machine:gha-oidc with role viewer, which is not configurable. AuthMiddleware confines that principal to GET on MACHINE_ROUTE_PATTERNS, currently ^/api/ariel/baselines/<id>$. Anything else returns MACHINE_ROUTE_FORBIDDEN. ariel_sync refuses machines explicitly.

Whether TRACEO_GHA_OIDC_TRUST is populated in production was not read for this card (UNVERIFIED). If it is empty, the feature is off.

sequenceDiagram
participant C as Caller
participant M as AuthMiddleware
participant J as BetterAuth JWKS
participant G as GitHub JWKS
participant H as Handler
C->>M: Authorization Bearer jwt
M->>M: read unverified iss and alg
alt iss is GitHub Actions
M->>G: RS256 key by kid
M->>M: verify aud, iss, trust entry
M->>H: machine viewer, GET allowlist only
else BetterAuth
M->>J: ES256 key by kid (5 min cache)
M->>H: UserContext sub, workspaceId, role
end
H-->>C: 200, 401 or 403

RBAC: a hierarchy that is switched off

Role ranks viewer(0) < editor(1) < admin(2) < owner(3) (rbac.py:56-72), and @require_permission(...) decorates create_requirement, update_requirement, delete_requirement and import_requirements. But require_permission returns early unless ENABLE_RBAC is truthy (rbac.py:421, :443), and that defaults to false (config.py:57-59). Decision 0104 recorded production traceo:mcp with ENABLE_RBAC unset on 2026-10-10. Decision 0109 restates that production runs with it off. The deployed decorators are therefore no-ops (not re-read on Railway for this card).

What actually guards writes is code that reads the principal directly, plus RLS:

WriteReal guard
relationshipsservices/relationships.py role check (editor/admin/owner) plus RLS (0108)
baselinesrefused for everyone, ariel_integration/policy.py (0104, see traceo-009)
ariel_syncadmin/owner only, never anonymous, never a machine
requirementsRLS INSERT/UPDATE/DELETE policies check the "user".role row (traceo-004)
flowchart TD
R["tool call create_requirement"] --> D{"ENABLE_RBAC on?"}
D -- "no (production default)" --> PASS["decorator passes through"]
D -- yes --> FLOOR{"role >= editor?"}
FLOOR -- no --> DENY["403"]
FLOOR -- yes --> PASS
PASS --> RLS{"RLS members_create_requirements: user row role in owner, admin, editor"}
RLS -- no --> REFUSE["InsufficientPrivilege"]
RLS -- yes --> OK["row written"]

Key Terms

  • JWKS → the issuer’s published public keys. jwks_cache.py fetches and caches them on each tier.
  • I-4 → Traceo’s invariant that bearer tokens are verified asymmetrically via JWKS, never with a shared secret.
  • Machine principal → machine:gha-oidc. Always viewer, and only allowed to GET routes on an allowlist.
  • ENABLE_RBAC → the env switch that turns @require_permission on. It defaults to off.

Q&A

Q: Which algorithm does the live JWKS at app.traceo.cat publish, and why not BetterAuth’s default? A: ES256 on a P-256 key. BetterAuth defaults to EdDSA/Ed25519, which python-jose cannot load, so every authenticated backend call failed until the plugin was pinned to ES256.

Q: What happened to the HS256 arm on each tier? A: It was deleted on both. The MCP tier went first (0083), then the engine (0094). The engine change also deleted the BETTER_AUTH_SECRET read and the path by which a token’s own role claim became a capability.

Q: What can a GitHub Actions OIDC token do once it is accepted? A: It becomes a fixed viewer machine principal confined to GET /api/ariel/baselines/<id>. Every other route is refused, and ariel_sync explicitly rejects machines.

Q: Does @require_permission block a viewer in production? A: No, by the recorded production config. The decorator does nothing unless ENABLE_RBAC is on, and decisions 0104 and 0109 record it off. Writes are guarded by principal checks in the service code and by RLS instead.

Examples

A petrova deep-probe workflow asks GitHub for an OIDC token with aud=https://mcp.traceo.cat and calls GET /api/ariel/baselines/BL-7. If its repository ids and workflow ref match a trust entry, the middleware lets the request through to the baseline route. That route then applies the 0104 read allowlist (BASELINE_READ_TENANT_IDS), the same as for any other caller. If it tries POST /api/requirements, it gets MACHINE_ROUTE_FORBIDDEN before any handler runs.

neighbors on the map