CI / Lint / Test / Vet (push) Successful in 5s
Closes #1. Closes gitea-mcp#58. Closes gitea-mcp#60. Three small changes to one decision point, kept together because they are the same surface and the same incident. 1. A JWT IS NEVER A STATIC-TOKEN MISMATCH (gitea-mcp#58) The fall-through reason was chosen by which comparison ran last, not by what the caller presented. So a JWT against a server with no JWT auth configured logged: reason=static_token_mismatch token_type=jwt status=401 The two fields contradict each other, and the sentence reads as "you presented the wrong token" when the truth is "this server could not have accepted any token of that kind". The misdirection reached the user — claude.ai surfaces "Authorization with gitea failed. You can check your credentials and permissions." — and a debugging session went into re-running the OAuth flow while the credentials were valid throughout. Now `jwt_auth_not_configured` for a JWT; `static_token_mismatch` stays, and is asserted to stay, for an actual static-token mismatch. Fixing the misleading case must not delete the accurate one. Note this is now the ONLY way to reach that branch with a JWT: since v0.6.0 a validator that merely could not reach its issuer is non-nil and answers ErrUnavailable -> 503. A nil validator means the server was never given an issuer. 2. SUCCESS IS LOGGED, WITH THE PATH THAT GRANTED IT (gitea-mcp#60) The middleware logged only on rejection. After re-authing a connector there was no way to tell whether a request used the static token or a JWT, only inference from which failures were absent. A log that proves a request failed, and nothing about how the successful ones succeeded, cannot answer the question anyone actually has during an auth incident. INFO mcp auth accepted auth_path=jwt caller=alice token_fp=... remote=... INFO mcp auth accepted auth_path=static token_fp=... remote=... Same disclosure rules as deny: a fingerprint, never the credential. The JWT subject is included because distinguishing WHO is the point of that path; `caller` is omitted rather than empty for the static path, which has no identity to report. 3. JWKS REFRESH INTERVAL IS CONFIGURABLE (#1) It was hardcoded at 1h, so an issuer rotating its signing key could leave every consumer rejecting valid JWTs for up to an hour. WithRefreshInterval, defaulting to 1h so nothing changes for existing callers. Variadic, per #1's own preferred option — a new positional parameter would force a coordinated bump across every consumer. Deliberately NOT read from the environment inside the library: a shared lib calling os.Getenv is a hidden dependency that surprises its consumers. Config ownership stays at the edge. The effective value is logged when an issuer resolves, which is #1's second acceptance criterion and the thing an operator needs during a key-rotation incident. The test asserts the configured value reaches the registration site via that line — a silently-ignored option being the failure mode worth pinning. Whether jwx then honours it is jwx's to test. One of my own assertions was wrong and the test caught it: tokenType() says "opaque", not "static", for a non-JWT credential. Corrected to the existing vocabulary rather than changing the vocabulary to match a new test. Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
340 lines
13 KiB
Go
340 lines
13 KiB
Go
// 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"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"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 one trusted issuer. jwksURI is resolved LAZILY: OIDC discovery
|
|
// may not be answerable when the process starts, and an issuer that is merely
|
|
// not-yet-serving must not become a permanent auth outage (mcp-chassis#4,
|
|
// gitea-mcp#57 — three recurrences, each ended by a human running
|
|
// `kubectl rollout restart`).
|
|
//
|
|
// Everything downstream of resolution was already correct. Validate reports
|
|
// ErrUnavailable when it cannot reach an issuer, and BearerMiddleware already
|
|
// answers 503-and-retry for that. Resolving eagerly in the constructor was the
|
|
// one step that made the correct path unreachable.
|
|
type issuerEntry struct {
|
|
issuer string
|
|
audience string
|
|
httpClient *http.Client
|
|
|
|
mu sync.Mutex
|
|
jwksURI string // "" until discovery has succeeded
|
|
lastAttempt time.Time // zero until the first attempt
|
|
lastErr error
|
|
}
|
|
|
|
// resolve returns this issuer's jwks_uri, running OIDC discovery if it has not
|
|
// succeeded yet. Failures are retried, no sooner than retryInterval apart, so a
|
|
// down issuer costs one discovery request per interval rather than one per
|
|
// inbound request.
|
|
func (e *issuerEntry) resolve(ctx context.Context, cache *jwk.Cache, retryInterval, refreshInterval time.Duration) (string, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if e.jwksURI != "" {
|
|
return e.jwksURI, nil
|
|
}
|
|
if !e.lastAttempt.IsZero() && time.Since(e.lastAttempt) < retryInterval {
|
|
// Back off, but report the real reason rather than a generic one — the
|
|
// operator needs the issuer's actual error, not "throttled".
|
|
return "", e.lastErr
|
|
}
|
|
e.lastAttempt = time.Now()
|
|
|
|
client := e.httpClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
jwksURI, err := discoverJWKSURI(ctx, client, e.issuer)
|
|
if err != nil {
|
|
e.lastErr = fmt.Errorf("%w: issuer %s: %v", ErrUnavailable, e.issuer, err)
|
|
return "", e.lastErr
|
|
}
|
|
|
|
regOpts := []jwk.RegisterOption{jwk.WithMinRefreshInterval(refreshInterval)}
|
|
if e.httpClient != nil {
|
|
regOpts = append(regOpts, jwk.WithHTTPClient(e.httpClient))
|
|
}
|
|
if err := cache.Register(jwksURI, regOpts...); err != nil {
|
|
e.lastErr = fmt.Errorf("%w: register jwks cache (%s): %v", ErrUnavailable, e.issuer, err)
|
|
return "", e.lastErr
|
|
}
|
|
if _, err := cache.Refresh(ctx, jwksURI); err != nil {
|
|
// Registered but unfetchable. Unregister so the next attempt re-runs
|
|
// discovery cleanly rather than inheriting a half-built entry.
|
|
_ = cache.Unregister(jwksURI)
|
|
e.lastErr = fmt.Errorf("%w: initial jwks fetch (%s): %v", ErrUnavailable, e.issuer, err)
|
|
return "", e.lastErr
|
|
}
|
|
|
|
e.jwksURI, e.lastErr = jwksURI, nil
|
|
// Stated, not assumed. An option that is silently ignored is worse than one
|
|
// that does not exist, and the effective interval is the thing an operator
|
|
// needs during a key-rotation incident.
|
|
slog.Info("jwt issuer resolved",
|
|
"issuer", e.issuer,
|
|
"jwks_uri", jwksURI,
|
|
"jwks_refresh_interval", refreshInterval.String(),
|
|
)
|
|
return jwksURI, nil
|
|
}
|
|
|
|
// Option configures a validator. Variadic so every existing caller compiles
|
|
// unchanged — a shared library cannot afford a breaking signature for this.
|
|
type Option func(*options)
|
|
|
|
type options struct {
|
|
retryInterval time.Duration
|
|
refreshInterval time.Duration
|
|
}
|
|
|
|
// defaultRetryInterval bounds how often a down issuer is re-probed. A readiness
|
|
// probe typically runs every 10s, so recovery lands within one interval of the
|
|
// issuer coming back — without turning an IdP outage into a discovery flood.
|
|
const defaultRetryInterval = 30 * time.Second
|
|
|
|
// defaultRefreshInterval is the JWKS cache's minimum refresh interval. 1h
|
|
// preserves the value that was hardcoded here before options existed; making it
|
|
// configurable is mcp-chassis#1.
|
|
const defaultRefreshInterval = time.Hour
|
|
|
|
// WithRetryInterval sets how long to wait before re-attempting OIDC discovery
|
|
// for an issuer that is not yet resolved. Zero disables the wait, which is for
|
|
// tests; production wants the default.
|
|
func WithRetryInterval(d time.Duration) Option {
|
|
return func(o *options) { o.retryInterval = d }
|
|
}
|
|
|
|
// WithRefreshInterval sets the JWKS cache's minimum refresh interval
|
|
// (mcp-chassis#1). It was hardcoded at 1h, so an issuer rotating its signing key
|
|
// could leave every consumer rejecting valid JWTs for up to an hour.
|
|
//
|
|
// Variadic rather than a new positional parameter, and deliberately not read
|
|
// from the process environment: a shared library reaching into os.Getenv is a
|
|
// hidden dependency that surprises consumers. Config ownership stays at the
|
|
// edge — the server parses its own env and passes the option.
|
|
func WithRefreshInterval(d time.Duration) Option {
|
|
return func(o *options) { o.refreshInterval = d }
|
|
}
|
|
|
|
// 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
|
|
opts options
|
|
}
|
|
|
|
// 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, opts ...Option) (*JWTValidator, error) {
|
|
if issuerURL == "" {
|
|
return nil, nil
|
|
}
|
|
return NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: issuerURL, Audience: audience}}, opts...)
|
|
}
|
|
|
|
// 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, opts ...Option) (*JWTValidator, error) {
|
|
o := options{retryInterval: defaultRetryInterval, refreshInterval: defaultRefreshInterval}
|
|
for _, fn := range opts {
|
|
fn(&o)
|
|
}
|
|
|
|
var entries []*issuerEntry
|
|
for _, ic := range issuers {
|
|
if ic.IssuerURL == "" {
|
|
continue
|
|
}
|
|
entries = append(entries, &issuerEntry{
|
|
issuer: ic.IssuerURL,
|
|
audience: ic.Audience,
|
|
httpClient: ic.HTTPClient,
|
|
})
|
|
}
|
|
if len(entries) == 0 {
|
|
// No issuer configured: JWT auth is genuinely disabled, and nil is the
|
|
// right answer. This is the ONE case where nil is not a lie, which is
|
|
// why the boot-race fix must not collapse it — see
|
|
// TestNewMultiJWTValidator_NoIssuer_StillReturnsNilNil.
|
|
return nil, nil
|
|
}
|
|
|
|
v := &JWTValidator{entries: entries, cache: jwk.NewCache(ctx), opts: o}
|
|
|
|
// Try once, eagerly, so a healthy start still warms the cache before the
|
|
// first request — but do NOT fail on it. A validator that exists and is
|
|
// temporarily unresolved is strictly better than no validator: the first
|
|
// answers 503-and-retry, the second answers 401 forever.
|
|
for _, e := range v.entries {
|
|
_, _ = e.resolve(ctx, v.cache, 0, o.refreshInterval)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// Ready reports whether every trusted issuer has been resolved, attempting
|
|
// resolution for any that have not. It is safe and intended to be called from a
|
|
// readiness probe: the probe's own cadence then drives retry, so recovery needs
|
|
// no background goroutine and no process restart.
|
|
//
|
|
// A nil validator is ready — "JWT auth disabled" is a configuration, not a
|
|
// fault.
|
|
func (v *JWTValidator) Ready(ctx context.Context) (bool, error) {
|
|
if v == nil {
|
|
return true, nil
|
|
}
|
|
for _, e := range v.entries {
|
|
if _, err := e.resolve(ctx, v.cache, v.opts.retryInterval, v.opts.refreshInterval); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
return true, 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 {
|
|
jwksURI, err := e.resolve(ctx, v.cache, v.opts.retryInterval, v.opts.refreshInterval)
|
|
if err != nil {
|
|
// Not resolved yet — transient by construction (resolve wraps
|
|
// ErrUnavailable). Try the next issuer.
|
|
lastErr = err
|
|
continue
|
|
}
|
|
keySet, err := v.cache.Get(ctx, 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")
|