// 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 ( "context" "encoding/json" "errors" "fmt" "net/http" "time" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ) // ErrUnavailable indicates JWT validation could not be COMPLETED because the // 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 an issuer // outage is distinguishable from a bad token (gitea-mcp#6). var ErrUnavailable = errors.New("jwt validation temporarily unavailable") // 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 // HTTPClient, if non-nil, is used to fetch THIS issuer's OIDC discovery // document and JWKS. Supply one to reach an issuer that needs a custom CA // and/or credential — notably the in-cluster k8s OIDC endpoint, which serves // its discovery/JWKS over the cluster CA AND requires an authenticated // (ServiceAccount-bearer) request (anonymous access is 401). nil uses // http.DefaultClient — correct for public issuers like Authentik. HTTPClient *http.Client } // 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 } // 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 } client := ic.HTTPClient if client == nil { client = http.DefaultClient } jwksURI, err := discoverJWKSURI(ctx, client, ic.IssuerURL) if err != nil { return nil, fmt.Errorf("issuer %s: %w", ic.IssuerURL, err) } regOpts := []jwk.RegisterOption{jwk.WithMinRefreshInterval(time.Hour)} if ic.HTTPClient != nil { regOpts = append(regOpts, jwk.WithHTTPClient(ic.HTTPClient)) } if err := cache.Register(jwksURI, regOpts...); 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, client *http.Client, issuerURL string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuerURL+"/.well-known/openid-configuration", nil) if err != nil { return "", fmt.Errorf("build oidc discovery request: %w", err) } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("fetch oidc discovery: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { 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 "", fmt.Errorf("decode oidc discovery: %w", err) } if doc.JWKSURI == "" { return "", fmt.Errorf("oidc discovery: empty jwks_uri") } return doc.JWKSURI, nil } // 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 } 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) } if lastErr == nil { lastErr = errDisabled } if !sawDecision { // Every issuer's JWKS was unreachable — lastErr already wraps ErrUnavailable. return "", lastErr } return "", lastErr } // errDisabled is the sentinel returned by Validate on a nil receiver. // Not exported because callers care about "not authorized" not "why"; // this lets consumers treat any err the same way without depending on // the specific value. var errDisabled = fmt.Errorf("jwt auth disabled")