BearerMiddleware now emits a structured slog line on every rejection with the reason (no_token / static_token_mismatch / jwt_invalid / jwt_dex_unavailable), client IP (X-Forwarded-For aware), presented token type (jwt/opaque), and a truncated SHA-256 fingerprint — never the raw token, so no secret material reaches stdout/log aggregation. Validate now tags a JWKS/Dex fetch failure with the exported ErrUnavailable sentinel (distinct from a present-but-invalid token). BearerMiddleware maps it to HTTP 503 with `WWW-Authenticate: Bearer error="temporarily_unavailable"`, so a transient Dex outage is distinguishable from a bad token instead of a silent generic 401. No signature change — logging goes through slog.Default(); existing consumers are unaffected until they set a default logger. Behavior reaches a consumer only when it bumps the chassis version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
4.4 KiB
Go
131 lines
4.4 KiB
Go
// 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"
|
|
"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 / Dex 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
|
|
// 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 {
|
|
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 {
|
|
// 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)
|
|
}
|
|
|
|
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")
|