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:
+113
-59
@@ -1,12 +1,16 @@
|
||||
// Package auth provides the Dex-JWT + static-Bearer authentication primitives
|
||||
// shared by every Mathias-owned MCP server (gitea-mcp, brain-mcp / ingestion,
|
||||
// future MCPs spawned from template-go-agent).
|
||||
// Package auth provides the Dex/Authentik-JWT + static-Bearer authentication
|
||||
// primitives shared by every Mathias-owned MCP server (gitea-mcp, brain-mcp /
|
||||
// ingestion, future MCPs spawned from template-go-agent).
|
||||
//
|
||||
// Replaces ~80 LOC of near-identical jwt.go in each consumer, ~50 LOC of
|
||||
// Bearer middleware, and ~25 LOC of RFC 9728 protected-resource metadata
|
||||
// handler. See `gitea.d-ma.be/mathias/infra` docs/superpowers/handoffs/
|
||||
// 2026-05-22-mcp-chassis-spike.md for the design rationale and the
|
||||
// abort-criterion check.
|
||||
//
|
||||
// A validator trusts a LIST of OIDC issuers (infra ADR-0011): one MCP server can
|
||||
// accept Authentik JWTs (interactive/web) AND k3s cluster ServiceAccount tokens
|
||||
// (in-cluster, audience-bound, kubelet-rotated) at once.
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -22,105 +26,155 @@ import (
|
||||
)
|
||||
|
||||
// ErrUnavailable indicates JWT validation could not be COMPLETED because the
|
||||
// JWKS / Dex endpoint was unreachable — a transient condition — as opposed to
|
||||
// JWKS / issuer endpoint was unreachable — a transient condition — as opposed to
|
||||
// the token being present and invalid. Callers (e.g. BearerMiddleware) map it
|
||||
// to HTTP 503 temporarily_unavailable rather than a generic 401, so a Dex
|
||||
// to HTTP 503 temporarily_unavailable rather than a generic 401, so an issuer
|
||||
// outage is distinguishable from a bad token (gitea-mcp#6).
|
||||
var ErrUnavailable = errors.New("jwt validation temporarily unavailable")
|
||||
|
||||
// JWTValidator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
||||
// Audience is optional; leave empty to skip audience validation.
|
||||
//
|
||||
// A nil *JWTValidator behaves as "JWT auth disabled" — Validate returns an
|
||||
// error without panicking. Callers can construct one validator at startup
|
||||
// keyed on whether DEX_ISSUER_URL is set, and pass nil through the rest of
|
||||
// the codebase without further branching.
|
||||
type JWTValidator struct {
|
||||
// IssuerConfig names one trusted OIDC issuer and its (optional) required
|
||||
// audience. Trusting a list of these is what lets a single MCP server accept
|
||||
// tokens from Authentik (D3/D4) and the k3s cluster OIDC issuer (D1 SA tokens)
|
||||
// simultaneously — infra ADR-0011 Decision 5.
|
||||
type IssuerConfig struct {
|
||||
IssuerURL string
|
||||
Audience string // "" = skip audience validation for this issuer
|
||||
}
|
||||
|
||||
// issuerEntry is a resolved IssuerConfig: OIDC discovery has run and jwks_uri is
|
||||
// registered in the shared cache.
|
||||
type issuerEntry struct {
|
||||
issuer string
|
||||
audience string
|
||||
jwksURI string
|
||||
cache *jwk.Cache
|
||||
}
|
||||
|
||||
// NewJWTValidator fetches the OIDC discovery document from issuerURL,
|
||||
// extracts jwks_uri, warms the JWKS cache, and returns a ready validator.
|
||||
// Empty issuerURL returns (nil, nil) so callers can use a single
|
||||
// constructor regardless of whether Dex is configured.
|
||||
// JWTValidator validates Bearer JWTs against one or more trusted OIDC issuers.
|
||||
//
|
||||
// A nil *JWTValidator behaves as "JWT auth disabled" — Validate returns an
|
||||
// error without panicking. Callers can construct one validator at startup keyed
|
||||
// on whether any issuer is configured, and pass nil through the rest of the
|
||||
// codebase without further branching.
|
||||
type JWTValidator struct {
|
||||
entries []issuerEntry
|
||||
cache *jwk.Cache
|
||||
}
|
||||
|
||||
// NewJWTValidator builds a single-issuer validator — the common case, and
|
||||
// backward compatible with every existing caller. Empty issuerURL returns
|
||||
// (nil, nil) so callers can use one constructor regardless of whether an issuer
|
||||
// is configured.
|
||||
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
||||
if issuerURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: issuerURL, Audience: audience}})
|
||||
}
|
||||
|
||||
// NewMultiJWTValidator builds a validator that trusts every issuer in the list.
|
||||
// For each, it fetches the OIDC discovery document, registers jwks_uri in a
|
||||
// shared cache, and warms it. Entries with an empty IssuerURL are skipped; an
|
||||
// empty/nil resulting set returns (nil, nil) — "JWT auth disabled".
|
||||
func NewMultiJWTValidator(ctx context.Context, issuers []IssuerConfig) (*JWTValidator, error) {
|
||||
cache := jwk.NewCache(ctx)
|
||||
var entries []issuerEntry
|
||||
for _, ic := range issuers {
|
||||
if ic.IssuerURL == "" {
|
||||
continue
|
||||
}
|
||||
jwksURI, err := discoverJWKSURI(ctx, ic.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issuer %s: %w", ic.IssuerURL, err)
|
||||
}
|
||||
if err := cache.Register(jwksURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
||||
return nil, fmt.Errorf("register jwks cache (%s): %w", ic.IssuerURL, err)
|
||||
}
|
||||
if _, err := cache.Refresh(ctx, jwksURI); err != nil {
|
||||
return nil, fmt.Errorf("initial jwks fetch (%s): %w", ic.IssuerURL, err)
|
||||
}
|
||||
entries = append(entries, issuerEntry{issuer: ic.IssuerURL, audience: ic.Audience, jwksURI: jwksURI})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &JWTValidator{entries: entries, cache: cache}, nil
|
||||
}
|
||||
|
||||
// discoverJWKSURI fetches the OIDC discovery document from issuerURL and returns
|
||||
// its jwks_uri.
|
||||
func discoverJWKSURI(ctx context.Context, issuerURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
issuerURL+"/.well-known/openid-configuration", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build oidc discovery request: %w", err)
|
||||
return "", fmt.Errorf("build oidc discovery request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||
return "", fmt.Errorf("fetch oidc discovery: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode)
|
||||
return "", fmt.Errorf("oidc discovery: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||
return "", fmt.Errorf("decode oidc discovery: %w", err)
|
||||
}
|
||||
if doc.JWKSURI == "" {
|
||||
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||
return "", fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||
}
|
||||
|
||||
cache := jwk.NewCache(ctx)
|
||||
if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
||||
return nil, fmt.Errorf("register jwks cache: %w", err)
|
||||
}
|
||||
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||
return nil, fmt.Errorf("initial jwks fetch: %w", err)
|
||||
}
|
||||
|
||||
return &JWTValidator{
|
||||
issuer: issuerURL,
|
||||
audience: audience,
|
||||
jwksURI: doc.JWKSURI,
|
||||
cache: cache,
|
||||
}, nil
|
||||
return doc.JWKSURI, nil
|
||||
}
|
||||
|
||||
// Validate parses and validates rawToken against the OIDC issuer. Returns
|
||||
// the subject claim on success. A nil receiver returns
|
||||
// (`""`, errDisabled) so callers can dispatch on `err != nil` without
|
||||
// nil-checks at every call site.
|
||||
// Validate parses rawToken against each trusted issuer and returns the subject
|
||||
// claim on the first success. If no issuer accepts it: when EVERY issuer's JWKS
|
||||
// was unreachable the error wraps ErrUnavailable (caller answers 503); otherwise
|
||||
// at least one issuer reached a signature/claims verdict and rejected it, so the
|
||||
// error is a definitive rejection (401). A nil receiver returns errDisabled.
|
||||
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) (string, error) {
|
||||
if v == nil {
|
||||
return "", errDisabled
|
||||
}
|
||||
|
||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||
if err != nil {
|
||||
// The JWKS could not be fetched — Dex/JWKS is unreachable, not a bad
|
||||
// token. Tag it ErrUnavailable so the caller can answer 503, not 401.
|
||||
return "", fmt.Errorf("%w: get jwks: %v", ErrUnavailable, err)
|
||||
var lastErr error
|
||||
sawDecision := false // at least one issuer reached a real verify verdict
|
||||
for _, e := range v.entries {
|
||||
keySet, err := v.cache.Get(ctx, e.jwksURI)
|
||||
if err != nil {
|
||||
// This issuer's JWKS is unreachable — transient. Try the next; only
|
||||
// if ALL issuers are unreachable do we surface ErrUnavailable.
|
||||
lastErr = fmt.Errorf("%w: get jwks (%s): %v", ErrUnavailable, e.issuer, err)
|
||||
continue
|
||||
}
|
||||
|
||||
opts := []jwt.ParseOption{
|
||||
jwt.WithKeySet(keySet),
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithIssuer(e.issuer),
|
||||
}
|
||||
if e.audience != "" {
|
||||
opts = append(opts, jwt.WithAudience(e.audience))
|
||||
}
|
||||
|
||||
tok, err := jwt.ParseString(rawToken, opts...)
|
||||
if err == nil {
|
||||
return tok.Subject(), nil
|
||||
}
|
||||
sawDecision = true
|
||||
lastErr = fmt.Errorf("validate jwt: %w", err)
|
||||
}
|
||||
|
||||
opts := []jwt.ParseOption{
|
||||
jwt.WithKeySet(keySet),
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithIssuer(v.issuer),
|
||||
if lastErr == nil {
|
||||
lastErr = errDisabled
|
||||
}
|
||||
if v.audience != "" {
|
||||
opts = append(opts, jwt.WithAudience(v.audience))
|
||||
if !sawDecision {
|
||||
// Every issuer's JWKS was unreachable — lastErr already wraps ErrUnavailable.
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
tok, err := jwt.ParseString(rawToken, opts...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("validate jwt: %w", err)
|
||||
}
|
||||
return tok.Subject(), nil
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// errDisabled is the sentinel returned by Validate on a nil receiver.
|
||||
|
||||
Reference in New Issue
Block a user