Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75eb8b831c |
+2
-1
@@ -18,10 +18,11 @@ import (
|
|||||||
func unavailableValidator(t *testing.T) *JWTValidator {
|
func unavailableValidator(t *testing.T) *JWTValidator {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &JWTValidator{
|
return &JWTValidator{
|
||||||
|
entries: []issuerEntry{{
|
||||||
issuer: "https://dex.example",
|
issuer: "https://dex.example",
|
||||||
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
|
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
|
||||||
|
}},
|
||||||
cache: jwk.NewCache(context.Background()),
|
cache: jwk.NewCache(context.Background()),
|
||||||
audience: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-52
@@ -1,12 +1,16 @@
|
|||||||
// Package auth provides the Dex-JWT + static-Bearer authentication primitives
|
// Package auth provides the Dex/Authentik-JWT + static-Bearer authentication
|
||||||
// shared by every Mathias-owned MCP server (gitea-mcp, brain-mcp / ingestion,
|
// primitives shared by every Mathias-owned MCP server (gitea-mcp, brain-mcp /
|
||||||
// future MCPs spawned from template-go-agent).
|
// ingestion, future MCPs spawned from template-go-agent).
|
||||||
//
|
//
|
||||||
// Replaces ~80 LOC of near-identical jwt.go in each consumer, ~50 LOC of
|
// 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
|
// Bearer middleware, and ~25 LOC of RFC 9728 protected-resource metadata
|
||||||
// handler. See `gitea.d-ma.be/mathias/infra` docs/superpowers/handoffs/
|
// handler. See `gitea.d-ma.be/mathias/infra` docs/superpowers/handoffs/
|
||||||
// 2026-05-22-mcp-chassis-spike.md for the design rationale and the
|
// 2026-05-22-mcp-chassis-spike.md for the design rationale and the
|
||||||
// abort-criterion check.
|
// 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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -22,106 +26,156 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrUnavailable indicates JWT validation could not be COMPLETED because the
|
// 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
|
// 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).
|
// outage is distinguishable from a bad token (gitea-mcp#6).
|
||||||
var ErrUnavailable = errors.New("jwt validation temporarily unavailable")
|
var ErrUnavailable = errors.New("jwt validation temporarily unavailable")
|
||||||
|
|
||||||
// JWTValidator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
// IssuerConfig names one trusted OIDC issuer and its (optional) required
|
||||||
// Audience is optional; leave empty to skip audience validation.
|
// 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)
|
||||||
// A nil *JWTValidator behaves as "JWT auth disabled" — Validate returns an
|
// simultaneously — infra ADR-0011 Decision 5.
|
||||||
// error without panicking. Callers can construct one validator at startup
|
type IssuerConfig struct {
|
||||||
// keyed on whether DEX_ISSUER_URL is set, and pass nil through the rest of
|
IssuerURL string
|
||||||
// the codebase without further branching.
|
Audience string // "" = skip audience validation for this issuer
|
||||||
type JWTValidator struct {
|
}
|
||||||
|
|
||||||
|
// issuerEntry is a resolved IssuerConfig: OIDC discovery has run and jwks_uri is
|
||||||
|
// registered in the shared cache.
|
||||||
|
type issuerEntry struct {
|
||||||
issuer string
|
issuer string
|
||||||
audience string
|
audience string
|
||||||
jwksURI 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
|
cache *jwk.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJWTValidator fetches the OIDC discovery document from issuerURL,
|
// NewJWTValidator builds a single-issuer validator — the common case, and
|
||||||
// extracts jwks_uri, warms the JWKS cache, and returns a ready validator.
|
// backward compatible with every existing caller. Empty issuerURL returns
|
||||||
// Empty issuerURL returns (nil, nil) so callers can use a single
|
// (nil, nil) so callers can use one constructor regardless of whether an issuer
|
||||||
// constructor regardless of whether Dex is configured.
|
// is configured.
|
||||||
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
||||||
if issuerURL == "" {
|
if issuerURL == "" {
|
||||||
return nil, nil
|
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,
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
issuerURL+"/.well-known/openid-configuration", nil)
|
issuerURL+"/.well-known/openid-configuration", nil)
|
||||||
if err != 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)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
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() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
if resp.StatusCode != http.StatusOK {
|
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 {
|
var doc struct {
|
||||||
JWKSURI string `json:"jwks_uri"`
|
JWKSURI string `json:"jwks_uri"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
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 == "" {
|
if doc.JWKSURI == "" {
|
||||||
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
return "", fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||||
|
}
|
||||||
|
return doc.JWKSURI, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := jwk.NewCache(ctx)
|
// Validate parses rawToken against each trusted issuer and returns the subject
|
||||||
if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
// claim on the first success. If no issuer accepts it: when EVERY issuer's JWKS
|
||||||
return nil, fmt.Errorf("register jwks cache: %w", err)
|
// was unreachable the error wraps ErrUnavailable (caller answers 503); otherwise
|
||||||
}
|
// at least one issuer reached a signature/claims verdict and rejected it, so the
|
||||||
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
// error is a definitive rejection (401). A nil receiver returns errDisabled.
|
||||||
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) {
|
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) (string, error) {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return "", errDisabled
|
return "", errDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
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 {
|
if err != nil {
|
||||||
// The JWKS could not be fetched — Dex/JWKS is unreachable, not a bad
|
// This issuer's JWKS is unreachable — transient. Try the next; only
|
||||||
// token. Tag it ErrUnavailable so the caller can answer 503, not 401.
|
// if ALL issuers are unreachable do we surface ErrUnavailable.
|
||||||
return "", fmt.Errorf("%w: get jwks: %v", ErrUnavailable, err)
|
lastErr = fmt.Errorf("%w: get jwks (%s): %v", ErrUnavailable, e.issuer, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := []jwt.ParseOption{
|
opts := []jwt.ParseOption{
|
||||||
jwt.WithKeySet(keySet),
|
jwt.WithKeySet(keySet),
|
||||||
jwt.WithValidate(true),
|
jwt.WithValidate(true),
|
||||||
jwt.WithIssuer(v.issuer),
|
jwt.WithIssuer(e.issuer),
|
||||||
}
|
}
|
||||||
if v.audience != "" {
|
if e.audience != "" {
|
||||||
opts = append(opts, jwt.WithAudience(v.audience))
|
opts = append(opts, jwt.WithAudience(e.audience))
|
||||||
}
|
}
|
||||||
|
|
||||||
tok, err := jwt.ParseString(rawToken, opts...)
|
tok, err := jwt.ParseString(rawToken, opts...)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return "", fmt.Errorf("validate jwt: %w", err)
|
|
||||||
}
|
|
||||||
return tok.Subject(), 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.
|
// errDisabled is the sentinel returned by Validate on a nil receiver.
|
||||||
// Not exported because callers care about "not authorized" not "why";
|
// Not exported because callers care about "not authorized" not "why";
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testIssuer is an in-process OIDC issuer: it serves an openid-configuration
|
||||||
|
// discovery doc + a JWKS, and mints signed JWTs — enough to exercise the real
|
||||||
|
// signature/issuer/audience validation path (which the chassis previously had
|
||||||
|
// no happy-path test for). Used to prove multi-issuer validation (ADR-0011).
|
||||||
|
type testIssuer struct {
|
||||||
|
url string
|
||||||
|
priv jwk.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestIssuer(t *testing.T) *testIssuer {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
priv, err := jwk.FromRaw(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, priv.Set(jwk.KeyIDKey, "test-kid"))
|
||||||
|
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
||||||
|
pub, err := priv.PublicKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
set := jwk.NewSet()
|
||||||
|
require.NoError(t, set.AddKey(pub))
|
||||||
|
|
||||||
|
ti := &testIssuer{priv: priv}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(set)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": ti.url,
|
||||||
|
"jwks_uri": ti.url + "/jwks",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
ti.url = srv.URL
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ti *testIssuer) mint(t *testing.T, aud, sub string) string {
|
||||||
|
t.Helper()
|
||||||
|
tok, err := jwt.NewBuilder().
|
||||||
|
Issuer(ti.url).
|
||||||
|
Subject(sub).
|
||||||
|
Audience([]string{aud}).
|
||||||
|
IssuedAt(time.Now()).
|
||||||
|
Expiration(time.Now().Add(time.Hour)).
|
||||||
|
Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, ti.priv))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core ADR-0011 claim: one validator, several trusted issuers (e.g.
|
||||||
|
// Authentik + the k3s cluster OIDC), a token from any trusted issuer validates.
|
||||||
|
func TestNewMultiJWTValidator_AcceptsEitherTrustedIssuer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a := newTestIssuer(t)
|
||||||
|
b := newTestIssuer(t)
|
||||||
|
|
||||||
|
v, err := NewMultiJWTValidator(ctx, []IssuerConfig{
|
||||||
|
{IssuerURL: a.url, Audience: "brain-mcp"},
|
||||||
|
{IssuerURL: b.url, Audience: "brain-mcp"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, v)
|
||||||
|
|
||||||
|
subA, err := v.Validate(ctx, a.mint(t, "brain-mcp", "alice"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "alice", subA)
|
||||||
|
|
||||||
|
subB, err := v.Validate(ctx, b.mint(t, "brain-mcp", "bob"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "bob", subB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token from an issuer NOT in the trusted list is rejected — definitively
|
||||||
|
// (401), not as a transient JWKS outage (503).
|
||||||
|
func TestNewMultiJWTValidator_RejectsUntrustedIssuer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
trusted := newTestIssuer(t)
|
||||||
|
untrusted := newTestIssuer(t)
|
||||||
|
|
||||||
|
v, err := NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: trusted.url, Audience: "brain-mcp"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = v.Validate(ctx, untrusted.mint(t, "brain-mcp", "eve"))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotErrorIs(t, err, ErrUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audience is enforced per issuer — the k8s-SA-token replay guard from the ADR
|
||||||
|
// spike. A token minted for a different audience is rejected.
|
||||||
|
func TestNewMultiJWTValidator_EnforcesPerIssuerAudience(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a := newTestIssuer(t)
|
||||||
|
|
||||||
|
v, err := NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: a.url, Audience: "brain-mcp"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = v.Validate(ctx, a.mint(t, "some-other-service", "alice"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: the existing single-issuer constructor still works and
|
||||||
|
// validates a real signed token end-to-end.
|
||||||
|
func TestNewJWTValidator_SingleIssuer_BackwardCompatible(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a := newTestIssuer(t)
|
||||||
|
|
||||||
|
v, err := NewJWTValidator(ctx, a.url, "brain-mcp")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, v)
|
||||||
|
|
||||||
|
sub, err := v.Validate(ctx, a.mint(t, "brain-mcp", "carol"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "carol", sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMultiJWTValidator_EmptyList_ReturnsNilNil(t *testing.T) {
|
||||||
|
v, err := NewMultiJWTValidator(context.Background(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, v)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user