feat(auth): multi-issuer JWT validation — accept k8s SA tokens (infra ADR-0011)

JWTValidator trusted a single issuer (iss matched exactly against DEX_ISSUER_URL),
which is why every in-cluster caller fell back to a static bearer. Add support
for a LIST of trusted issuers so one MCP server can accept Authentik JWTs
(interactive/web) AND k3s cluster ServiceAccount tokens (in-cluster, audience-
bound, kubelet-rotated) at once — the enabler for ADR-0011.

- Add IssuerConfig{IssuerURL, Audience} + NewMultiJWTValidator([]IssuerConfig).
- Refactor JWTValidator to hold []issuerEntry + a shared jwk.Cache; Validate
  tries each trusted issuer, returns the subject on first success. All-issuers-
  JWKS-unreachable -> ErrUnavailable (503); any definitive reject -> 401.
- NewJWTValidator (single issuer) and BearerMiddleware signatures UNCHANGED —
  existing consumers (gitea-mcp, ingestion) compile and behave identically.
- Add auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS +
  token minting) — the chassis previously had NO happy-path JWT test. Covers
  accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer
  audience enforcement, single-issuer backward compat.

Proven viable by the ADR-0011 live-cluster spike (k3s OIDC/JWKS validates a
projected SA token). Follow-up: wire gitea-mcp/brain-mcp/ingestion to also trust
the k3s issuer + mount an audience-scoped projected SA token on one pod.

Refs infra ADR-0011; supersedes the sidecar in infra#183. (Tracked as
hyperguild#77, which was misfiled — the chassis is this repo, not hyperguild.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 23:14:56 +02:00
co-authored by Claude Opus 4.8
parent d5aa5b992c
commit 75eb8b831c
3 changed files with 261 additions and 63 deletions
+5 -4
View File
@@ -18,10 +18,11 @@ import (
func unavailableValidator(t *testing.T) *JWTValidator {
t.Helper()
return &JWTValidator{
issuer: "https://dex.example",
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
cache: jwk.NewCache(context.Background()),
audience: "",
entries: []issuerEntry{{
issuer: "https://dex.example",
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
}},
cache: jwk.NewCache(context.Background()),
}
}