// 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). // // 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. package auth import ( "context" "encoding/json" "fmt" "net/http" "time" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ) // 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 { 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. func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) { if issuerURL == "" { return nil, nil } 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) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, 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) } 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) } if doc.JWKSURI == "" { return nil, 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 } // 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. 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 { return "", fmt.Errorf("get jwks: %w", err) } opts := []jwt.ParseOption{ jwt.WithKeySet(keySet), jwt.WithValidate(true), jwt.WithIssuer(v.issuer), } if v.audience != "" { opts = append(opts, jwt.WithAudience(v.audience)) } tok, err := jwt.ParseString(rawToken, opts...) if err != nil { return "", fmt.Errorf("validate jwt: %w", err) } return tok.Subject(), nil } // 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")