Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,15 @@ REDMINE_API_KEY=your_api_key
# REDMINE_PER_USER_TRUST_PROXY=true
# REDMINE_PER_USER_AUDIT_IDENTITY=false

# --- oauth mode (not yet implemented; only presence is validated) ---
# --- oauth mode (advanced; requires --transport http) ---
# Each request supplies its own Authorization: Bearer token, validated by RFC
# 7662 introspection against Redmine's Doorkeeper. See docs/oauth-setup.md.
# Scope enforcement and discovery documents are not live yet.
# REDMINE_AUTH_MODE=oauth
# REDMINE_MCP_BASE_URL=http://localhost:3040
# REDMINE_INTROSPECT_CLIENT_ID=your-doorkeeper-application-uid
# REDMINE_INTROSPECT_CLIENT_SECRET=your-doorkeeper-application-secret
# REDMINE_OAUTH_TOKEN_CACHE_TTL_SECONDS=60

# --- Attachment store (no tools use it yet; get_redmine_attachment lands in
# a later sub-phase) ---
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `REDMINE_AUTH_MODE=oauth` now works end to end for bearer-token
authentication: an axum middleware guards the whole `/mcp` route (including
`initialize`), extracting the inbound `Authorization: Bearer` token and
validating it by RFC 7662 introspection against Redmine's Doorkeeper,
cached by a SHA-256 digest of the token (never the token itself) with a TTL
capped by the token's own `exp`. A missing/malformed/invalid token gets a
`401` carrying `WWW-Authenticate: Bearer resource_metadata="..."`; a broken
or misconfigured introspection endpoint gets a `503` with `Retry-After`,
never a `401`. The validated token is forwarded to Redmine verbatim.
Requires the new `REDMINE_INTROSPECT_CLIENT_ID`/
`REDMINE_INTROSPECT_CLIENT_SECRET`(`_FILE`) variables; `oauth` on the stdio
transport is now a startup error, matching `legacy-per-user`. Scope
enforcement, discovery documents, and `/revoke` are not implemented yet —
see `docs/oauth-setup.md`.
- `REDMINE_AUTH_MODE=legacy-per-user` is now implemented: each HTTP request
carries its own Redmine credential in `X-Redmine-API-Key` instead of the
server holding one shared key. No ambient fallback and no cross-request
Expand Down
63 changes: 63 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ repository = "https://github.com/mimi1vx/ruprogress-mcp"
[workspace.dependencies]
rmcp = { version = "=3.1.1", default-features = false }
tokio = { version = "1.53", default-features = false }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1.2"
Expand All @@ -38,6 +38,7 @@ wiremock = "0.6"
futures-core = "0.3"
futures-util = { version = "0.3", default-features = false, features = ["std"] }
base64 = "0.22"
sha2 = "0.11"

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ before changing `SERVER_HOST`.
- Full tool parity with the reference server (~51 tools).
- Both stdio and streamable HTTP transports.
- Four auth modes: `legacy` and `legacy-per-user` implemented (the latter
documented in `docs/legacy-per-user-auth.md`); `oauth`/`oauth-proxy` not
documented in `docs/legacy-per-user-auth.md`); `oauth` implemented for
bearer-token introspection (documented in `docs/oauth-setup.md`; scope
enforcement and discovery documents are not live yet); `oauth-proxy` not
yet.
- A reusable `redmine-client` crate, independent of MCP.

Expand Down
97 changes: 94 additions & 3 deletions crates/redmine-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::time::{Duration, Instant};
use bytes::Bytes;
use futures_core::Stream;
use futures_util::TryStreamExt as _;
use secrecy::{ExposeSecret as _, SecretString};
use serde::Serialize;
use serde::de::DeserializeOwned;
use url::Url;
Expand All @@ -18,9 +19,9 @@ use crate::ids::{
TimeEntryId, UserId, VersionId, WikiTitle,
};
use crate::model::{
BareCollection, Collection, attachment, custom_field, enumeration, issue, issue_category,
issue_status, journal, membership, project, query, relation, role, search, time_entry, tracker,
upload, user, version, wiki,
BareCollection, Collection, attachment, custom_field, enumeration, introspection, issue,
issue_category, issue_status, journal, membership, project, query, relation, role, search,
time_entry, tracker, upload, user, version, wiki,
};
use crate::page::{Limits, Page};
use crate::retry::{self, RetryPolicy};
Expand Down Expand Up @@ -477,6 +478,32 @@ impl Scoped<'_> {
self.read_json(resp, "response").await
}

/// Send `form` as `application/x-www-form-urlencoded`, returning the raw
/// response for the caller to decode (or discard). Not covered by the
/// retry policy for a different reason than the JSON POST helpers: it
/// exists only for the OAuth introspection/revocation endpoints, which
/// this crate's retry rule (idempotent verbs only) already excludes as a
/// `POST`.
async fn post_form<B: Serialize>(
&self,
path: &str,
form: &B,
) -> crate::Result<reqwest::Response> {
let url = self.build_url(path, None)?;
let template = self.credential.apply(self.inner.http.post(url)).form(form);
self.send_with_retry(&http::Method::POST, &template).await
}

/// Like [`Self::post_form`], decoding the response body as JSON.
async fn post_form_json<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
form: &B,
) -> crate::Result<T> {
let resp = self.post_form(path, form).await?;
self.read_json(resp, "response").await
}

pub(crate) async fn put_json<B: Serialize>(&self, path: &str, body: &B) -> crate::Result<()> {
let url = self.build_url(path, None)?;
let template = self.credential.apply(self.inner.http.put(url)).json(body);
Expand Down Expand Up @@ -727,6 +754,70 @@ impl Scoped<'_> {
Ok(env.user)
}

/// `POST /oauth/introspect` (RFC 7662). The scoping credential must be
/// `Credential::Basic { user: client_id, pass: client_secret }` for the
/// confidential OAuth client registered for token introspection — this
/// is one of the two methods on this type where the scoping credential
/// is not an end-user identity.
///
/// # Errors
///
/// Returns [`Error::Unauthorized`]/[`Error::Forbidden`] if the scoping
/// client credentials are rejected, [`Error::NotFound`] if the
/// introspection route is unmounted (Redmine's `allow_token_introspection`
/// defaults to `false`), or a transport/decode error otherwise. Never
/// itself an error for an inactive/expired/unknown token — that is
/// `Introspection::active`.
pub async fn introspect_token(
&self,
token: &SecretString,
) -> crate::Result<introspection::Introspection> {
#[derive(Serialize)]
struct Form<'a> {
token: &'a str,
token_type_hint: &'a str,
}
self.post_form_json(
"oauth/introspect",
&Form {
token: token.expose_secret(),
token_type_hint: "access_token",
},
)
.await
}

/// `POST /oauth/revoke` (RFC 7009). Same scoping-credential requirement
/// as [`Self::introspect_token`]. Per RFC 7009 a `200` is success whether
/// or not `token` was a token this client ever issued — revoking an
/// unknown token is not an error.
///
/// # Errors
///
/// Returns [`Error::Unauthorized`]/[`Error::Forbidden`] if the scoping
/// client credentials are rejected, or a transport error otherwise.
pub async fn revoke_token(
&self,
token: &SecretString,
hint: Option<&str>,
) -> crate::Result<()> {
#[derive(Serialize)]
struct Form<'a> {
token: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
token_type_hint: Option<&'a str>,
}
self.post_form(
"oauth/revoke",
&Form {
token: token.expose_secret(),
token_type_hint: hint,
},
)
.await?;
Ok(())
}

/// `GET /projects.json`.
///
/// # Errors
Expand Down
Loading