JWTValidator trusted a single issuer (iss matched exactly against DEX_ISSUER_URL),
which is why every in-cluster caller fell back to a static bearer. Add support
for a LIST of trusted issuers so one MCP server can accept Authentik JWTs
(interactive/web) AND k3s cluster ServiceAccount tokens (in-cluster, audience-
bound, kubelet-rotated) at once — the enabler for ADR-0011.
- Add IssuerConfig{IssuerURL, Audience} + NewMultiJWTValidator([]IssuerConfig).
- Refactor JWTValidator to hold []issuerEntry + a shared jwk.Cache; Validate
tries each trusted issuer, returns the subject on first success. All-issuers-
JWKS-unreachable -> ErrUnavailable (503); any definitive reject -> 401.
- NewJWTValidator (single issuer) and BearerMiddleware signatures UNCHANGED —
existing consumers (gitea-mcp, ingestion) compile and behave identically.
- Add auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS +
token minting) — the chassis previously had NO happy-path JWT test. Covers
accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer
audience enforcement, single-issuer backward compat.
Proven viable by the ADR-0011 live-cluster spike (k3s OIDC/JWKS validates a
projected SA token). Follow-up: wire gitea-mcp/brain-mcp/ingestion to also trust
the k3s issuer + mount an audience-scoped projected SA token on one pod.
Refs infra ADR-0011; supersedes the sidecar in infra#183. (Tracked as
hyperguild#77, which was misfiled — the chassis is this repo, not hyperguild.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
5.7 KiB
Go
181 lines
5.7 KiB
Go
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()
|
|
|
|
called := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
h := BearerMiddleware("supersecret", nil, "brain", "", next)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer supersecret")
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.True(t, called, "next must be called on valid static token")
|
|
require.Equal(t, http.StatusNoContent, rec.Code)
|
|
}
|
|
|
|
func TestBearerMiddleware_NoHeader_401NoChallengeWhenMetadataEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := BearerMiddleware("any", nil, "brain", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
require.Empty(t, rec.Header().Get("WWW-Authenticate"))
|
|
}
|
|
|
|
func TestBearerMiddleware_NoHeader_EmitsChallengeWhenMetadataSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := BearerMiddleware("any", nil, "brain",
|
|
"https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource",
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
require.Equal(t,
|
|
`Bearer realm="brain", resource_metadata="https://brain-mcp.d-ma.be/.well-known/oauth-protected-resource"`,
|
|
rec.Header().Get("WWW-Authenticate"),
|
|
)
|
|
}
|
|
|
|
func TestBearerMiddleware_WrongStaticToken_401(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := BearerMiddleware("expected", nil, "brain", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("next must NOT be called on wrong token")
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong")
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
}
|
|
|
|
func TestBearerMiddleware_EmptyBearer_401(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := BearerMiddleware("expected", nil, "brain", "",
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("next must NOT be called on empty bearer")
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer ")
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
}
|
|
|
|
func TestBearerMiddleware_StaticOnly_NilValidator_OK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Verifies that JWT-disabled deployments (validator == nil) work end-to-end.
|
|
called := false
|
|
h := BearerMiddleware("tok", nil, "brain", "",
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer tok")
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.True(t, called)
|
|
}
|
|
|
|
func TestJWTValidator_NilReturnsError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var v *JWTValidator
|
|
subj, err := v.Validate(t.Context(), "anything")
|
|
require.Empty(t, subj)
|
|
require.Error(t, err)
|
|
}
|