Files
mcp-chassis/auth/bearer_test.go
T
mathiasandClaude Opus 4.8 d5aa5b992c feat(auth): audit-log rejections + 503 on Dex outage (gitea-mcp#9, #6)
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>
2026-07-03 23:16:32 +02:00

180 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{
issuer: "https://dex.example",
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
cache: jwk.NewCache(context.Background()),
audience: "",
}
}
// #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)
}