package auth import ( "bytes" "encoding/json" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/require" ) // captureLogs swaps the default logger for one writing JSON to a buffer, and // returns a decoder for the lines it collected. Matches the existing pattern in // TestBearerMiddleware_AuditLogsRejection. func captureLogs(t *testing.T) func() []map[string]any { t.Helper() var buf bytes.Buffer prev := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))) t.Cleanup(func() { slog.SetDefault(prev) }) return func() []map[string]any { var out []map[string]any for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") { if line == "" { continue } var m map[string]any require.NoError(t, json.Unmarshal([]byte(line), &m)) out = append(out, m) } return out } } func call(h http.Handler, authz string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/mcp", nil) if authz != "" { req.Header.Set("Authorization", authz) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // A well-formed JWT looks like this to tokenType(): three dotted segments. The // content does not matter — nothing parses it on the paths under test. const jwtShaped = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.c2ln" // gitea-mcp#58: a JWT must never be rejected with a STATIC-token reason. // // The log said: // // reason=static_token_mismatch token_type=jwt status=401 // // which reads as "the caller presented the wrong token" when the truth was "this // server has no JWT auth configured, so a correct token had no chance". The // fields contradict each other and the misdirection reached the user: claude.ai // surfaced "Authorization with gitea failed. You can check your credentials and // permissions." The credentials were valid; a session went into re-running the // OAuth flow before anyone read the server log. func TestBearerMiddleware_JWTAgainstStaticOnlyServer_IsNotAStaticMismatch(t *testing.T) { logs := captureLogs(t) h := BearerMiddleware("the-static-token", nil, "gitea", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not reach next") })) rec := call(h, "Bearer "+jwtShaped) require.Equal(t, http.StatusUnauthorized, rec.Code) lines := logs() require.Len(t, lines, 1) require.Equal(t, "jwt", lines[0]["token_type"]) require.NotEqual(t, "static_token_mismatch", lines[0]["reason"], "a JWT path must never report a static-token failure") require.Equal(t, "jwt_auth_not_configured", lines[0]["reason"]) } // The other half of the same claim: static_token_mismatch must still be reported // for an actual static-token mismatch. Fixing the misleading case must not // remove the accurate one. func TestBearerMiddleware_WrongOpaqueToken_IsStillAStaticMismatch(t *testing.T) { logs := captureLogs(t) h := BearerMiddleware("the-static-token", nil, "gitea", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not reach next") })) rec := call(h, "Bearer definitely-not-it") require.Equal(t, http.StatusUnauthorized, rec.Code) lines := logs() require.Len(t, lines, 1) // "opaque" is tokenType()'s existing word for a non-JWT credential; the // point is only that it is NOT "jwt". require.Equal(t, "opaque", lines[0]["token_type"]) require.Equal(t, "static_token_mismatch", lines[0]["reason"]) } // gitea-mcp#60: the auth path is silent on success. // // After re-authing the claude.ai connector there was no way to tell from the // logs whether a request had used the static token or a JWT — only inference. // A middleware that logs solely on rejection can prove a request failed and // nothing about how the ones that worked, worked. func TestBearerMiddleware_LogsTheAcceptedPath(t *testing.T) { t.Run("static", func(t *testing.T) { logs := captureLogs(t) reached := false h := BearerMiddleware("the-static-token", nil, "gitea", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) require.Equal(t, http.StatusOK, call(h, "Bearer the-static-token").Code) require.True(t, reached) lines := logs() require.Len(t, lines, 1, "a successful auth must leave exactly one line") require.Equal(t, "mcp auth accepted", lines[0]["msg"]) require.Equal(t, "static", lines[0]["auth_path"]) require.NotEmpty(t, lines[0]["token_fp"]) require.NotContains(t, lines[0], "token", "the raw token must never be logged") }) t.Run("jwt", func(t *testing.T) { iss := newTestIssuer(t) v, err := NewMultiJWTValidator(t.Context(), []IssuerConfig{{IssuerURL: iss.url, Audience: "aud"}}) require.NoError(t, err) logs := captureLogs(t) h := BearerMiddleware("the-static-token", v, "gitea", "", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) require.Equal(t, http.StatusOK, call(h, "Bearer "+iss.mint(t, "aud", "alice")).Code) lines := logs() require.Len(t, lines, 1) require.Equal(t, "mcp auth accepted", lines[0]["msg"]) require.Equal(t, "jwt", lines[0]["auth_path"]) require.Equal(t, "alice", lines[0]["caller"], "the JWT subject is the whole reason to distinguish this path") }) } // The fingerprint is what makes a log line correlatable across requests without // being a credential. It must be stable for one token and different for another, // and it must never be a prefix of the token itself. func TestFingerprint_StableAndNotDerivableBackToTheToken(t *testing.T) { a := fingerprint("token-one") b := fingerprint("token-two") require.Equal(t, a, fingerprint("token-one"), "same token, same fingerprint") require.NotEqual(t, a, b) require.NotContains(t, "token-one", a) require.False(t, strings.HasPrefix("token-one", a)) }