CI / Lint / Test / Vet (push) Successful in 5s
Closes #4. Closes gitea-mcp#57. Filed 2026-07-17, filed again 2026-09-07, recurred 2026-09-14. Each occurrence ended with a human running `kubectl rollout restart`; the last one ran for six days and was found by chance while gathering context for unrelated work. THE BUG NewMultiJWTValidator resolved OIDC discovery eagerly and returned (nil, err) if the issuer was not answering. BearerMiddleware reads a nil validator as "this server does not do JWT auth", so it fell through to the static-token comparison and rejected every Authentik JWT: WARN mcp auth rejected reason=static_token_mismatch token_type=jwt status=401 Two states collapsed into one nil: static-token-only by design -> nil correct JWT required, IdP down at boot -> nil wrong, and permanent Permanent because discovery was attempted exactly once, at construction. WHY THE FIX IS SMALL Everything downstream was already right. Validate distinguishes ErrUnavailable (issuer unreachable -> 503, client retries) from a definitive rejection (-> 401), and BearerMiddleware already honours that — gitea-mcp#6 got it right. That path was simply unreachable when the validator was never built. So: build it anyway. jwks_uri now resolves lazily on first use and re-resolves on failure, no sooner than WithRetryInterval apart (default 30s) so a down issuer costs one discovery request per interval, not one per request. Until it resolves, Validate returns ErrUnavailable and the existing machinery does the rest. The constructor still warms discovery eagerly on a healthy start; it just no longer fails on it. Ready(ctx) reports whether every issuer resolved, and attempts resolution for any that have not. That makes a readiness probe the retry loop — no background goroutine, no context to leak, and the platform's own cadence drives recovery. A nil validator is Ready: "JWT auth disabled" is a configuration, not a fault. WHAT DID NOT CHANGE No issuer configured still returns (nil, nil). That is the one case where nil is not a lie, and collapsing it would turn every static-only server into a JWT server. Pinned by TestNewMultiJWTValidator_NoIssuer_StillReturnsNilNil. ONE EXISTING TEST CHANGED ITS MECHANISM, NOT ITS CLAIM TestNewMultiJWTValidator_UsesPerIssuerHTTPClientForAuthedFetch asserted that the constructor ERRORS when discovery 401s without the fetch credential. It cannot, now, by design. Rewritten to assert the property that actually mattered, and more directly than before: without the credential the validator is not Ready AND a correctly-minted token from that very issuer is refused as ErrUnavailable. The old test only checked that a constructor returned an error. Written test-first: jwt_bootrace_test.go red (API absent), then green. Also red in the behavioural sense — the constructor-errors assertion above had to be rewritten precisely because the semantics genuinely changed. Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ./... ok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
5.8 KiB
Go
181 lines
5.8 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", // already "resolved", 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)
|
|
}
|