feat: dual-accept ZITADEL + legacy PASETO across the playground gateway and API (BRA-468, BRA-469) - #24
Open
raludi wants to merge 5 commits into
Open
feat: dual-accept ZITADEL + legacy PASETO across the playground gateway and API (BRA-468, BRA-469)#24raludi wants to merge 5 commits into
raludi wants to merge 5 commits into
Conversation
The portal is moving its auth to ZITADEL behind a BFF that forwards the user's RS256 access token, so the SPOE agent now routes each token format to its own branch: legacy PASETO stays as-is behind ACCEPT_LEGACY_PASETO, and a new JWT branch validates against the issuer's JWKS (fetched at startup, background refresh plus rate-limited refetch on unknown kid) with an RS256 allow-list, exact iss, aud-array containment of the project id and real exp/nbf checks. The injected X-User-ID comes exclusively from the urn:hedera:portal_user_id claim — the portal's legacy user id, stamped by a ZITADEL Action from user metadata — never from sub, whose ZITADEL value would silently detach every stored BYOK key and per-user rate limit. A valid token without the claim is refused. Verified against a live local ZITADEL v4.17.1: real user token with the claim passes, a machine token without it is refused, and validation runs in ~0.3ms — well inside HAProxy's 15ms SPOE processing timeout. Helm chart, deploy workflow and README carry the new configuration; with the new vars unset the branch stays off and nothing changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DEL branch (BRA-468) ai-assistant's local middleware now mirrors the SPOE agent's dual accept (jose against the ZITADEL JWKS, identity only from the portal user-id claim), so ENVIRONMENT=local behaves like the gateway. New dev/ harness replays the api-gateway chart's HAProxy front against the real thing: agent + header-reflecting echo + a Host-rewriting ZITADEL mirror (ZITADEL routes instances by Host, containers cannot hit a host-local instance directly). Verified end to end: ZITADEL token -> 200 with the portal user id injected as X-User-ID, legacy PAT -> 200 with its own id, machine token without the claim -> 401, no token -> 401, foreign path -> 404. The harness also measured why the gateway budget had to move: the first request on a fresh SPOE connection costs ~18ms (TCP + HELLO), follow-ups ~5ms and validation ~0.3ms, so the 15ms processing timeout killed every first transaction and looped on 401s. The chart now runs 50ms with the numbers documented in place. The spoe-auth Dockerfile builds the whole package instead of main.go alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
…rvability (BRA-468) Addresses the external review of the dual-accept work: - JWKS knobs split and bounded: JWKS_CACHE_TTL (background refresh, 10m) and JWKS_HTTP_TIMEOUT (every fetch, unknown-kid refetches included, 5s — the library default was a full minute inside the request path). Rotation is now covered by a deterministic test against a mutable JWKS: new kid verifies via the synchronous refetch, retired keys keep validating from the cache. The one-request 401 right after a rotation is documented as a deliberate trade-off in the gateway configmap and README. - Local parity was fail-open on aud: with the env unset, jose skips the audience check where the Go agent refuses to boot. The middleware now throws without issuer/audience, honors ACCEPT_LEGACY_PASETO and reads the clock-skew env. - Dual-accept observability without secrets: one always-on structured line per verification (branch + outcome + reason) — the signal for retiring the legacy branch — and the pre-existing debug dump no longer prints Authorization headers or cookie values (the chart ships debug enabled, so it was logging live bearer tokens); tokens are described by shape and length, cookies by name. - Optional ZITADEL_ALLOWED_CLIENT_IDS pins the OAuth clients (ZITADEL carries the authorized party in client_id, never azp); empty keeps the project-audience design. Verified: 11 Go tests green including the rotation one, tsc clean, and the gateway harness passes both branches with the correct identities while its logs contain no token material. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…A-469) Dual-accept middleware: eyJ-prefixed Bearer/cookie tokens verify against the ZITADEL JWKS (RS256, issuer, audience, clock skew) and are refused without the urn:hedera:portal_user_id claim; everything else stays on the legacy PASETO branch. The old fail-open (skip all auth when PUBLIC_KEY is empty) now only survives when BOTH branches are unconfigured, and startup fails on an issuer without audience. ACCEPT_LEGACY_PASETO=false retires the legacy branch (BRA-427). Traefik CORS reviewed, unchanged during the dual window. Refs: BRA-469 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round-api configuration Refs: BRA-469 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a ZITADEL JWT branch next to the legacy PASETO one in both token
verifiers of playground-backend, so the identity migration can run with a
dual-accept window and a clean kill switch:
spoe-auth(HAProxy gateway infront of ai-assistant) and
playground-api(Piston executor behind Traefik).Issues Resolved
Changes Made
spoe-auth (BRA-468)
jwt.go: JWKS verifier (keyfunc/jwkset — background refresh + synchronous unknown-kid refetch, RS256 only, issuer/audience/exp/leeway, optionalclient_idallow-list, fail-closed on a missingurn:hedera:portal_user_id).main.go: branch dispatch by token shape,ACCEPT_LEGACY_PASETOkill switch, refuses to start with no branch configured, always-onauth branch=… outcome=…log line, redacted debug dumps (no tokens/cookies).dev/compose harness (HAProxy + agent + echo + zitadel-mirror) exercising both branches end to end.timeout processing15ms → 50ms with in-repo measurement notes.playground-api (BRA-469)
src/auth.js:eyJ-shaped Bearer/cookie tokens verify against the ZITADEL JWKS viajose(dynamic import, CommonJS); refused without theurn:hedera:portal_user_idclaim (machine tokens carry none). Everything else stays on the legacy PASETO path, byte-for-byte behavior preserved.PUBLIC_KEYandZITADEL_ISSUERare empty (logged as local-dev only);ZITADEL_ISSUERwithoutZITADEL_AUDIENCErefuses to start.src/config.js:accept_legacy_paseto,zitadel_issuer,zitadel_jwks_url,zitadel_audience,jwt_user_claim,jwt_clock_skew_seconds.dynamic.yamlCORS: reviewed, deliberately unchanged during the dual window (note in file).How to Test
cd app/spoe-auth && go test ./...— 11 tests green.cd app/spoe-auth/dev && ZITADEL_AUDIENCE=<project id> PASETO_V4_PUBLIC_KEY_HEX=<key> docker compose -f docker-compose.dev.yaml up --build, then curl:8081/api/playground/assistant/...with a ZITADEL human token (200 +X-User-ID), a machine token (401) and a legacy PASETO (200).Auth, run against the local ZITADEL PoC): dual-accept, ZITADEL-only, kill-switch, dev passthrough and misconfig cases — 18/18 green (human JWT 200; valid JWT without the portal claim 401; wrong audience 401; PASETO session 200; garbage 401; cookie paths andx-api-keyunchanged).SPOE_AUTH_ZITADEL_*vars /ZITADEL_ISSUERenvs are set;ACCEPT_LEGACY_PASETO=falseretires the legacy branch (BRA-427).🤖 Generated with Claude Code