Files
gitea-mcp/internal/auth/passthrough.go
T
mathiasandClaude Opus 5 e1e9a2e30e
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 16s
CD / Build & Import (push) Successful in 16s
feat(auth): every accepted request names the path that granted it
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>
2026-09-14 20:59:04 +02:00

63 lines
2.4 KiB
Go

package auth
import (
"context"
"log/slog"
"net/http"
"strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// TokenValidator asks the upstream service who a bearer token belongs to.
type TokenValidator interface {
ValidateToken(ctx context.Context, token string) (username string, ok bool)
}
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
// if the request's bearer token validates directly against Gitea, it's used
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
// the server's shared default token. Any other bearer (static token, JWT, or
// none) falls through to fallback unchanged — this only adds a capability, it
// never removes the existing auth paths.
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
if hasBearer && token != "" {
if login, ok := validator.ValidateToken(r.Context(), token); ok {
// gitea-mcp#60. This middleware logged nothing at all, so there
// was no way to tell a PAT pass-through request from one that
// fell through to the shared JWT/static path — only inference
// from which failures were absent.
//
// It matters more here than on the other two paths: this is
// 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 the same field on its own accepted/rejected lines, and
// two different fingerprints for one token cannot be correlated.
slog.Info("mcp auth accepted",
"auth_path", "passthrough",
"caller", login,
"token_fp", chassisauth.Fingerprint(token),
"path", r.URL.Path,
)
ctx := withCaller(r.Context(), login)
ctx = gitea.WithToken(ctx, token)
onValid.ServeHTTP(w, r.WithContext(ctx))
return
}
// A token that is not a valid PAT falls through SILENTLY. The
// chassis owns the verdict; logging here too would double-count
// every static-token request, since all of them reach this
// middleware first.
}
fallback.ServeHTTP(w, r)
})
}