feat(auth): implement oauth bearer-token authentication mode - #1
Merged
Conversation
REDMINE_AUTH_MODE=oauth previously parsed but every request 500'd with
"not yet implemented". This makes it work end to end: an MCP client
presents its own Redmine Doorkeeper access token as `Authorization:
Bearer`, this server validates it by RFC 7662 introspection, and the
token is forwarded upstream verbatim so every Redmine call runs as
that user.
The auth check moves from the per-tool choke point (where
legacy-per-user checks it) to an axum middleware in front of the
whole /mcp route, including `initialize`: an MCP client only learns
it needs a token, and where to get one, from a real HTTP 401 carrying
`WWW-Authenticate: Bearer resource_metadata="..."`, which a JSON-RPC-
level error inside a 200 response can't provide. /livez, /readyz,
/health, /files/{uuid}, and CORS preflight stay unauthenticated.
Introspection results are cached keyed by a SHA-256 digest of the
token (never the token itself), with a positive TTL capped by the
token's own exp and a short fixed TTL for a negative result, so a
cache breach can't leak a usable credential and a token can't outlive
its own expiry in the cache. Introspection reuses the existing
redmine-client (scoped to Credential::Basic with the confidential
introspection client) rather than a second HTTP client, so it inherits
the same TLS/CA/mTLS configuration Redmine calls already use.
Introspection being unreachable or misconfigured answers 503 with
Retry-After, never 401 — a broken introspection endpoint must not
send every connected client through a pointless re-authorization flow
or look like a fleet-wide auth failure.
oauth mode now additionally requires REDMINE_INTROSPECT_CLIENT_ID and
REDMINE_INTROSPECT_CLIENT_SECRET (or _FILE), refuses to start on the
stdio transport (no per-request header to challenge over), and
tightens REDMINE_MCP_BASE_URL validation, since that value is now
embedded in a challenge every client receives.
Not yet covered: scope enforcement, RFC 9728/8414 discovery documents,
and POST /revoke — documented as such in docs/oauth-setup.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
REDMINE_AUTH_MODE=oauthnow works end to end. Previously it parsed(only
REDMINE_MCP_BASE_URLwas validated) but every request 500'dwith "not yet implemented". An MCP client can now present its own
Redmine Doorkeeper access token as
Authorization: Bearer, thisserver validates it by RFC 7662 introspection, and the token is
forwarded upstream verbatim so every Redmine call runs as that user
with that token's permissions.
Changes
Scoped::introspect_token/revoke_token(RFC7662/7009), a new
Introspectionmodel, and a form-encoded POSThelper. Both methods scope to
Credential::Basic— the confidentialintrospection client, not an end-user identity — and reuse the
existing pooled
reqwest::Clientrather than a second one, sointrospection inherits the same TLS/CA/mTLS configuration as every
other Redmine call.
exactly one header, one space, visible ASCII, ≤4096 bytes), a token
verifier with a cache keyed by SHA-256 digest of the token (never
the token itself), and the axum middleware that issues the
401 WWW-Authenticate/503 Retry-Afterchallenge./mcproute,including
initialize— unlikelegacy-per-user's per-tool chokepoint, an MCP client only discovers it needs a token (and where to
get one) from a real HTTP
401, which a JSON-RPC-level error insidea
200can't provide./livez,/readyz,/health,/files/{uuid}, and CORS preflight stay unauthenticated.exp(plus a fixed shortTTL for an inactive/expired result), so a token can never outlive
its own expiry in the cache.
503,never
401— never sends a connected client through a pointlessre-authorization flow because the server itself is broken.
oauthmode now additionally requiresREDMINE_INTROSPECT_CLIENT_ID/
REDMINE_INTROSPECT_CLIENT_SECRET(or_FILE), refuses to starton the
stdiotransport, and tightensREDMINE_MCP_BASE_URLvalidation (absolute, no userinfo/query/fragment) since that value
is now embedded in the challenge.
sha2dependency (MIT/Apache-2.0), used only for the cache key.Breaking Changes
REDMINE_AUTH_MODE=oauthdeployments now require two additional envvars (
REDMINE_INTROSPECT_CLIENT_ID/_SECRET) and cannot run onstdio. Not a regression from a working state — this mode 500'd onevery request before this change — but any
.envthat setREDMINE_AUTH_MODE=oauthneeds those two vars added. Seedocs/oauth-setup.md.Not yet implemented
Per-tool scope enforcement, RFC 9728/8414 discovery documents, and
POST /revokeare follow-up work, called out explicitly indocs/oauth-setup.mdand the new ADR.Testing
cargo fmt --all --check,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo test --workspace --all-features,cargo deny checkall clean.redmine-clientwire tests (introspect/revokehappy path, error mapping, no-token-leak), 15
config.rsvalidationtests, 15
auth::oauthunit tests (bearer parsing, cache expiry,challenge building, redaction), and 16 end-to-end tests covering the
happy path, every rejection path, cache-hit-once vs.
cache-disabled behavior, two-concurrent-tokens isolation, and the
unauthenticated-route matrix.
tracingoutput atTRACEacrosssuccess/401/503 and asserts the token, client secret, and form body
never appear) was manually verified to fail against a deliberately
introduced token-leaking log line, then confirmed passing again
after reverting it.
legacy/legacy-per-usertest suites pass unchanged; nomiddleware is mounted in those modes.
Related
Vendored reference behaviour from
jztan/redmine-mcp-server,docs/oauth-setup.mdandsrc/redmine_mcp_server/oauth_scopes.py.