gitea-mcp's PAT pass-through authenticates ahead of BearerMiddleware and emits
its own `mcp auth accepted` line (gitea-mcp#60). It needs the same fingerprint,
not an equivalent one: two different fingerprints for the same token across two
log lines cannot be correlated, which is the entire purpose of the field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes#1. Closes gitea-mcp#58. Closes gitea-mcp#60.
Three small changes to one decision point, kept together because they are the
same surface and the same incident.
1. A JWT IS NEVER A STATIC-TOKEN MISMATCH (gitea-mcp#58)
The fall-through reason was chosen by which comparison ran last, not by what the
caller presented. So a JWT against a server with no JWT auth configured logged:
reason=static_token_mismatch token_type=jwt status=401
The two fields contradict each other, and the sentence reads as "you presented
the wrong token" when the truth is "this server could not have accepted any
token of that kind". The misdirection reached the user — claude.ai surfaces
"Authorization with gitea failed. You can check your credentials and
permissions." — and a debugging session went into re-running the OAuth flow
while the credentials were valid throughout.
Now `jwt_auth_not_configured` for a JWT; `static_token_mismatch` stays, and is
asserted to stay, for an actual static-token mismatch. Fixing the misleading
case must not delete the accurate one.
Note this is now the ONLY way to reach that branch with a JWT: since v0.6.0 a
validator that merely could not reach its issuer is non-nil and answers
ErrUnavailable -> 503. A nil validator means the server was never given an
issuer.
2. SUCCESS IS LOGGED, WITH THE PATH THAT GRANTED IT (gitea-mcp#60)
The middleware logged only on rejection. After re-authing a connector there was
no way to tell whether a request used the static token or a JWT, only inference
from which failures were absent. A log that proves a request failed, and nothing
about how the successful ones succeeded, cannot answer the question anyone
actually has during an auth incident.
INFO mcp auth accepted auth_path=jwt caller=alice token_fp=... remote=...
INFO mcp auth accepted auth_path=static token_fp=... remote=...
Same disclosure rules as deny: a fingerprint, never the credential. The JWT
subject is included because distinguishing WHO is the point of that path;
`caller` is omitted rather than empty for the static path, which has no identity
to report.
3. JWKS REFRESH INTERVAL IS CONFIGURABLE (#1)
It was hardcoded at 1h, so an issuer rotating its signing key could leave every
consumer rejecting valid JWTs for up to an hour. WithRefreshInterval, defaulting
to 1h so nothing changes for existing callers.
Variadic, per #1's own preferred option — a new positional parameter would force
a coordinated bump across every consumer. Deliberately NOT read from the
environment inside the library: a shared lib calling os.Getenv is a hidden
dependency that surprises its consumers. Config ownership stays at the edge.
The effective value is logged when an issuer resolves, which is #1's second
acceptance criterion and the thing an operator needs during a key-rotation
incident. The test asserts the configured value reaches the registration site
via that line — a silently-ignored option being the failure mode worth pinning.
Whether jwx then honours it is jwx's to test.
One of my own assertions was wrong and the test caught it: tokenType() says
"opaque", not "static", for a non-JWT credential. Corrected to the existing
vocabulary rather than changing the vocabulary to match a new test.
Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ok.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Shared Go library for Mathias-owned MCP servers, born from spike S3 of
the 2026-05 homelab architecture review (see
gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md
for the viability assessment and abort-criterion check).
Provides three primitives every MCP server today re-implements:
- auth.JWTValidator — Dex OIDC JWT validation. nil-safe (nil = "JWT
disabled"), audience-optional. Lifted from the identical
~80-LOC implementations in gitea-mcp and hyperguild/ingestion.
- auth.BearerMiddleware — dual-mode static-Bearer-or-Dex-JWT gate.
Static wins first to avoid emitting a WWW-Authenticate challenge
that would flip claude.ai's MCP client into OAuth discovery for
static-only deployments. The fall-through 401 emits the RFC 9728
resource_metadata header only when explicitly configured.
- auth.ProtectedResourceHandler — RFC 9728
/.well-known/oauth-protected-resource metadata document handler.
Test coverage exercises every branch (static OK, JWT-disabled,
empty bearer, wrong static, with-challenge vs without-challenge,
nil-validator-Validate). go test -race clean.
Deps: github.com/lestrrat-go/jwx/v2 (already a dep of every consumer)
and testify (test-only). No new transitive deps.
First migration target: gitea-mcp. If that port lands in one PR
(abort criterion from spec), brain-mcp (ingestion) follows. Otherwise
chassis reverts per the spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>