Skip to content

feat(auth): implement oauth bearer-token authentication mode - #1

Merged
mimi1vx merged 1 commit into
mainfrom
feat/oauth-bearer-introspection
Aug 9, 2026
Merged

feat(auth): implement oauth bearer-token authentication mode#1
mimi1vx merged 1 commit into
mainfrom
feat/oauth-bearer-introspection

Conversation

@mimi1vx

@mimi1vx mimi1vx commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

REDMINE_AUTH_MODE=oauth now works end to end. Previously it parsed
(only REDMINE_MCP_BASE_URL was validated) but every request 500'd
with "not yet implemented". An MCP client can now present 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
with that token's permissions.

Changes

  • redmine-client: Scoped::introspect_token/revoke_token (RFC
    7662/7009), a new Introspection model, and a form-encoded POST
    helper. Both methods scope to Credential::Basic — the confidential
    introspection client, not an end-user identity — and reuse the
    existing pooled reqwest::Client rather than a second one, so
    introspection inherits the same TLS/CA/mTLS configuration as every
    other Redmine call.
  • auth::oauth (new): bearer-header extraction (RFC 7235-strict:
    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-After challenge.
  • The auth check moves in front of the whole /mcp route,
    including initialize — unlike legacy-per-user's per-tool choke
    point, 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 inside
    a 200 can't provide. /livez, /readyz, /health,
    /files/{uuid}, and CORS preflight stay unauthenticated.
  • Cache TTL is capped by the token's own exp (plus a fixed short
    TTL for an inactive/expired result), so a token can never outlive
    its own expiry in the cache.
  • Introspection being unreachable or misconfigured answers 503,
    never 401
    — never sends a connected client through a pointless
    re-authorization flow because the server itself is broken.
  • oauth mode now additionally requires REDMINE_INTROSPECT_CLIENT_ID
    / REDMINE_INTROSPECT_CLIENT_SECRET (or _FILE), refuses to start
    on the stdio transport, and tightens REDMINE_MCP_BASE_URL
    validation (absolute, no userinfo/query/fragment) since that value
    is now embedded in the challenge.
  • New sha2 dependency (MIT/Apache-2.0), used only for the cache key.

Breaking Changes

REDMINE_AUTH_MODE=oauth deployments now require two additional env
vars (REDMINE_INTROSPECT_CLIENT_ID/_SECRET) and cannot run on
stdio. Not a regression from a working state — this mode 500'd on
every request before this change — but any .env that set
REDMINE_AUTH_MODE=oauth needs those two vars added. See
docs/oauth-setup.md.

Not yet implemented

Per-tool scope enforcement, RFC 9728/8414 discovery documents, and
POST /revoke are follow-up work, called out explicitly in
docs/oauth-setup.md and the new ADR.

Testing

  • cargo fmt --all --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace --all-features, cargo deny check all clean.
  • ~53 new tests: 7 redmine-client wire tests (introspect/revoke
    happy path, error mapping, no-token-leak), 15 config.rs validation
    tests, 15 auth::oauth unit 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.
  • A redaction test (captures real tracing output at TRACE across
    success/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.
  • Existing legacy/legacy-per-user test suites pass unchanged; no
    middleware is mounted in those modes.

Related

Vendored reference behaviour from jztan/redmine-mcp-server,
docs/oauth-setup.md and src/redmine_mcp_server/oauth_scopes.py.

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.
@mimi1vx
mimi1vx merged commit 84373db into main Aug 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant