Skip to content

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
developfrom
feature/identity-platform
Open

raludi wants to merge 5 commits into
developfrom
feature/identity-platform

Conversation

@raludi

@raludi raludi commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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 in
front of ai-assistant) and playground-api (Piston executor behind Traefik).

Issues Resolved

  • BRA-468: Accept ZITADEL JWTs in spoe-auth next to legacy PASETO
  • BRA-469: ZITADEL auth in playground-api (Piston) + Traefik CORS review

Changes Made

spoe-auth (BRA-468)

  • jwt.go: JWKS verifier (keyfunc/jwkset — background refresh + synchronous unknown-kid refetch, RS256 only, issuer/audience/exp/leeway, optional client_id allow-list, fail-closed on a missing urn:hedera:portal_user_id).
  • main.go: branch dispatch by token shape, ACCEPT_LEGACY_PASETO kill switch, refuses to start with no branch configured, always-on auth branch=… outcome=… log line, redacted debug dumps (no tokens/cookies).
  • 11 Go tests incl. deterministic JWKS rotation; dev/ compose harness (HAProxy + agent + echo + zitadel-mirror) exercising both branches end to end.
  • Helm chart + deploy workflow: new env plumbing; SPOE timeout processing 15ms → 50ms with in-repo measurement notes.
  • Node parity in ai-assistant local middleware (jose), audience check fail-closed.

playground-api (BRA-469)

  • src/auth.js: eyJ-shaped Bearer/cookie tokens verify against the ZITADEL JWKS via jose (dynamic import, CommonJS); refused without the urn:hedera:portal_user_id claim (machine tokens carry none). Everything else stays on the legacy PASETO path, byte-for-byte behavior preserved.
  • Fail-open narrowed: auth is skipped only when BOTH PUBLIC_KEY and ZITADEL_ISSUER are empty (logged as local-dev only); ZITADEL_ISSUER without ZITADEL_AUDIENCE refuses to start.
  • src/config.js: accept_legacy_paseto, zitadel_issuer, zitadel_jwks_url, zitadel_audience, jwt_user_claim, jwt_clock_skew_seconds.
  • VM compose template + local compose: documented (commented) ZITADEL envs, off by default.
  • Traefik dynamic.yaml CORS: reviewed, deliberately unchanged during the dual window (note in file).

How to Test

  1. cd app/spoe-auth && go test ./... — 11 tests green.
  2. Gateway harness: 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).
  3. playground-api matrix (standalone Express + 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 and x-api-key unchanged).
  4. Rollout stays off until SPOE_AUTH_ZITADEL_* vars / ZITADEL_ISSUER envs are set; ACCEPT_LEGACY_PASETO=false retires the legacy branch (BRA-427).

🤖 Generated with Claude Code

raludi and others added 2 commits August 17, 2026 15:30
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-665387e357

wiz-665387e357 Bot commented Aug 17, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 1 Info
Software Management Finding Software Management Findings -
Total 1 Info

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

raludi and others added 2 commits August 17, 2026 16:42
…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>
@raludi raludi changed the title Accept ZITADEL JWTs at the gateway next to legacy PASETO (BRA-468) feat: dual-accept ZITADEL + legacy PASETO across the playground gateway and API (BRA-468, BRA-469) Aug 17, 2026
…round-api configuration

Refs: BRA-469

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant