main
2
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
29e461e108 |
feat(auth): name the path that granted access, and stop blaming the caller
CI / Lint / Test / Vet (push) Successful in 5s
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> |
||
|
|
3257364f23 |
fix(auth): a validator whose issuer is down at boot is built anyway
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> |