Closes #60. Closes #58 (fixed in mcp-chassis v0.7.0, consumed here). Three auth paths reach this server and none of them said so on success. PassthroughMiddleware logged nothing at all, success or failure. While verifying #59 end to end there was no way to confirm from the logs whether calls were using the new PAT pass-through — the caller's own Gitea PAT, the caller's own identity — or falling through to the shared JWT/static path. The only available reasoning was inference from which failures were absent. INFO mcp auth accepted auth_path=passthrough caller=alice token_fp=... INFO mcp auth accepted auth_path=jwt caller=mathiasbq token_fp=... INFO mcp auth accepted auth_path=static token_fp=... The first line matters more than the other two. Pass-through is the path where a request acts as a SPECIFIC Gitea user; an audit trail that cannot say which identity performed a write is not an audit trail. chassisauth.Fingerprint, not a local equivalent. The chassis emits token_fp on its own accepted and rejected lines, and two different fingerprints for the same token cannot be correlated — which is the only reason the field exists. Exported from the chassis in v0.7.1 for exactly this. A token that is not a valid PAT still falls through SILENTLY. The chassis owns the verdict, and logging here as well would double-count every static-token request, since all of them reach this middleware first. Asserted, because "logs nothing on the failure path" is indistinguishable from "was never called". Also lands #58 by consuming mcp-chassis v0.7.0: a JWT presented to a server with no JWT auth configured now reports jwt_auth_not_configured rather than static_token_mismatch. That log line is what sent a debugging session into re-running the OAuth flow while the credentials were valid the whole time. Verified: task check EXIT=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
3.1 KiB
Go
84 lines
3.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type stubValidator struct{ login string }
|
|
|
|
func (s stubValidator) ValidateToken(context.Context, string) (string, bool) {
|
|
return s.login, s.login != ""
|
|
}
|
|
|
|
// gitea-mcp#60: PassthroughMiddleware logged nothing at all, success or failure.
|
|
//
|
|
// While verifying #59 end to end there was no way to confirm from the server
|
|
// logs whether calls were using the new PAT pass-through (caller's own Gitea
|
|
// PAT, own identity) or falling through to the shared JWT/static path. The only
|
|
// way to reason about it was inference. The chassis names its two paths as of
|
|
// v0.7.0; this is the third.
|
|
//
|
|
// This matters more than the other two: pass-through is the path where the
|
|
// request acts as a SPECIFIC Gitea user. An audit trail that cannot say which
|
|
// identity performed a write is not an audit trail.
|
|
func TestPassthroughMiddleware_LogsTheAcceptedPath(t *testing.T) {
|
|
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) })
|
|
|
|
reached := false
|
|
h := PassthroughMiddleware(stubValidator{login: "alice"},
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }),
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not fall back") }))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer a-real-gitea-pat")
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
require.True(t, reached)
|
|
|
|
var line map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &line))
|
|
require.Equal(t, "mcp auth accepted", line["msg"])
|
|
require.Equal(t, "passthrough", line["auth_path"])
|
|
require.Equal(t, "alice", line["caller"])
|
|
require.NotEmpty(t, line["token_fp"], "correlatable without being a credential")
|
|
|
|
for k, v := range line {
|
|
require.NotContains(t, []any{v}, "a-real-gitea-pat",
|
|
"the PAT must never appear in any field, including %q", k)
|
|
}
|
|
}
|
|
|
|
// A token that is not a valid PAT must fall through silently to the chassis,
|
|
// which owns the rejection log. Logging here as well would double-count every
|
|
// static-token request — every one of them reaches this middleware first.
|
|
func TestPassthroughMiddleware_FallsThroughWithoutLogging(t *testing.T) {
|
|
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) })
|
|
|
|
fellBack := false
|
|
h := PassthroughMiddleware(stubValidator{},
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not accept") }),
|
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fellBack = true }))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
|
req.Header.Set("Authorization", "Bearer not-a-pat")
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
require.True(t, fellBack)
|
|
require.Empty(t, strings.TrimSpace(buf.String()),
|
|
"the chassis owns the verdict; logging here too would double-count every static-token call")
|
|
}
|