Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e16913248 | ||
|
|
75eb8b831c | ||
|
|
d5aa5b992c | ||
|
|
69d389d3f7 |
@@ -48,7 +48,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||
"git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -82,7 +82,7 @@ func mcpHandler() http.Handler { /* per-domain */ return nil }
|
||||
## Versioning
|
||||
|
||||
Trunk-based development on `main`. Tagged with semver. Consumers pin
|
||||
specific tags (`go.mod` `require gitea.d-ma.be/mathias/mcp-chassis v0.x.y`)
|
||||
specific tags (`go.mod` `require git.d-ma.be/mathias/mcp-chassis v0.x.y`)
|
||||
and bump deliberately.
|
||||
|
||||
Migrations are documented per-consumer in the consumer's CHANGELOG / commits.
|
||||
|
||||
+78
-5
@@ -1,7 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -44,7 +49,7 @@ func BearerMiddleware(
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rawToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || rawToken == "" {
|
||||
unauthorized(w, realm, resourceMetadataURL)
|
||||
deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "no_token", rawToken)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,21 +62,89 @@ func BearerMiddleware(
|
||||
|
||||
// 2. Then Dex JWT, if configured.
|
||||
if validator != nil {
|
||||
if _, err := validator.Validate(r.Context(), rawToken); err == nil {
|
||||
switch _, err := validator.Validate(r.Context(), rawToken); {
|
||||
case err == nil:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
// Dex/JWKS is down — the token might be fine, we just can't
|
||||
// check. Answer 503 so the caller retries rather than treating
|
||||
// it as a hard auth failure (gitea-mcp#6).
|
||||
deny(w, r, realm, resourceMetadataURL, http.StatusServiceUnavailable, "jwt_dex_unavailable", rawToken)
|
||||
return
|
||||
default:
|
||||
deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "jwt_invalid", rawToken)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reject with an OAuth resource-metadata challenge if configured.
|
||||
unauthorized(w, realm, resourceMetadataURL)
|
||||
// 3. Static token was wrong and no JWT validator is configured.
|
||||
deny(w, r, realm, resourceMetadataURL, http.StatusUnauthorized, "static_token_mismatch", rawToken)
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, realm, resourceMetadataURL string) {
|
||||
// deny writes the auth rejection and emits a structured audit log line
|
||||
// (gitea-mcp#9). The log records the reason, client IP, presented token type,
|
||||
// and a non-reversible token fingerprint — never the raw token, so nothing
|
||||
// secret lands in stdout/log aggregation. slog adds the timestamp.
|
||||
func deny(w http.ResponseWriter, r *http.Request, realm, resourceMetadataURL string, status int, reason, rawToken string) {
|
||||
slog.Warn("mcp auth rejected",
|
||||
"reason", reason,
|
||||
"status", status,
|
||||
"remote", clientIP(r),
|
||||
"token_type", tokenType(rawToken),
|
||||
"token_fp", fingerprint(rawToken),
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
|
||||
if status == http.StatusServiceUnavailable {
|
||||
// RFC 6750 §3.1: temporarily_unavailable signals a transient failure.
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`", error="temporarily_unavailable"`)
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if resourceMetadataURL != "" {
|
||||
w.Header().Set("WWW-Authenticate",
|
||||
`Bearer realm="`+realm+`", resource_metadata="`+resourceMetadataURL+`"`)
|
||||
}
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// clientIP prefers the left-most X-Forwarded-For entry (the original client
|
||||
// behind the reverse proxy) and falls back to the transport peer.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i > 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// tokenType classifies the presented credential without revealing it: a
|
||||
// three-segment dotted value is a JWT, anything else an opaque/static token.
|
||||
func tokenType(raw string) string {
|
||||
switch {
|
||||
case raw == "":
|
||||
return "none"
|
||||
case strings.Count(raw, ".") == 2:
|
||||
return "jwt"
|
||||
default:
|
||||
return "opaque"
|
||||
}
|
||||
}
|
||||
|
||||
// fingerprint is a short, non-reversible correlator (truncated SHA-256) so ops
|
||||
// can group repeated attempts from one client without ever logging token bytes.
|
||||
func fingerprint(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// unavailableValidator returns a validator whose JWKS cache cannot be fetched,
|
||||
// so Validate fails with ErrUnavailable — simulating a Dex/JWKS outage.
|
||||
func unavailableValidator(t *testing.T) *JWTValidator {
|
||||
t.Helper()
|
||||
return &JWTValidator{
|
||||
entries: []issuerEntry{{
|
||||
issuer: "https://dex.example",
|
||||
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
|
||||
}},
|
||||
cache: jwk.NewCache(context.Background()),
|
||||
}
|
||||
}
|
||||
|
||||
// #6: a JWKS/Dex outage at validation time is ErrUnavailable, distinct from a
|
||||
// present-but-invalid token.
|
||||
func TestValidate_JWKSUnreachable_IsUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := unavailableValidator(t).Validate(context.Background(), "some.jwt.token")
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrUnavailable)
|
||||
}
|
||||
|
||||
// #6: when JWT validation can't complete because Dex is down, answer 503
|
||||
// temporarily_unavailable rather than a generic 401.
|
||||
func TestBearerMiddleware_DexDown_503(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := BearerMiddleware("static-tok", unavailableValidator(t), "gitea", "",
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("next must NOT be called when Dex is down")
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer aaa.bbb.ccc") // not the static token → JWT path
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.Contains(t, rec.Header().Get("WWW-Authenticate"), `error="temporarily_unavailable"`)
|
||||
}
|
||||
|
||||
// #9: every rejection is audit-logged with reason, remote, token type, and a
|
||||
// non-reversible token fingerprint — never the raw token.
|
||||
func TestBearerMiddleware_AuditLogsRejection(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
h := BearerMiddleware("expected-secret", nil, "gitea", "",
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-secret-value")
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
logged := buf.String()
|
||||
assert.Contains(t, logged, "static_token_mismatch")
|
||||
assert.Contains(t, logged, "token_fp")
|
||||
assert.NotContains(t, logged, "wrong-secret-value", "raw token must never be logged")
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_StaticTokenWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+135
-56
@@ -1,17 +1,22 @@
|
||||
// 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).
|
||||
// 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"
|
||||
@@ -20,97 +25,171 @@ import (
|
||||
"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 {
|
||||
// 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
|
||||
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.
|
||||
// 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 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 := client.Do(req)
|
||||
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() }()
|
||||
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 {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
||||
return "", 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
|
||||
return doc.JWKSURI, 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.
|
||||
// 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
|
||||
}
|
||||
|
||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get jwks: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
opts := []jwt.ParseOption{
|
||||
jwt.WithKeySet(keySet),
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithIssuer(v.issuer),
|
||||
if lastErr == nil {
|
||||
lastErr = errDisabled
|
||||
}
|
||||
if v.audience != "" {
|
||||
opts = append(opts, jwt.WithAudience(v.audience))
|
||||
if !sawDecision {
|
||||
// Every issuer's JWKS was unreachable — lastErr already wraps ErrUnavailable.
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
tok, err := jwt.ParseString(rawToken, opts...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("validate jwt: %w", err)
|
||||
}
|
||||
return tok.Subject(), nil
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// errDisabled is the sentinel returned by Validate on a nil receiver.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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
|
||||
requireBearer string // if set, discovery+JWKS return 401 without this bearer
|
||||
}
|
||||
|
||||
func (ti *testIssuer) authed(r *http.Request) bool {
|
||||
return ti.requireBearer == "" || r.Header.Get("Authorization") == "Bearer "+ti.requireBearer
|
||||
}
|
||||
|
||||
// bearerRT adds a static Authorization header to every request — a minimal stand-in
|
||||
// for the in-cluster case where the JWKS fetch carries the pod's SA token.
|
||||
type bearerRT struct{ token string }
|
||||
|
||||
func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r.Header.Set("Authorization", "Bearer "+b.token)
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}
|
||||
|
||||
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, r *http.Request) {
|
||||
if !ti.authed(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(set)
|
||||
})
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !ti.authed(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
// The in-cluster k8s issuer requires an AUTHENTICATED discovery/JWKS fetch
|
||||
// (anonymous is 401, confirmed against k3s). IssuerConfig.HTTPClient must carry
|
||||
// that credential (the server pod's SA token) + trust the cluster CA.
|
||||
func TestNewMultiJWTValidator_UsesPerIssuerHTTPClientForAuthedFetch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
iss := newTestIssuer(t)
|
||||
iss.requireBearer = "fetch-secret" // discovery + JWKS now demand this bearer
|
||||
|
||||
// nil client (http.DefaultClient, no credential) → discovery 401 → build fails.
|
||||
_, err := NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: iss.url, Audience: "sa-poc"}})
|
||||
require.Error(t, err, "unauthenticated fetch must fail against an auth-gated issuer")
|
||||
|
||||
// A client that presents the fetch credential → build succeeds, token validates.
|
||||
client := &http.Client{Transport: bearerRT{token: "fetch-secret"}}
|
||||
v, err := NewMultiJWTValidator(ctx, []IssuerConfig{
|
||||
{IssuerURL: iss.url, Audience: "sa-poc", HTTPClient: client},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sub, err := v.Validate(ctx, iss.mint(t, "sa-poc", "poc-pod"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "poc-pod", sub)
|
||||
}
|
||||
Reference in New Issue
Block a user