From 51dda169315bafad87731370b9cc98226336fd04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:14:41 +0000 Subject: [PATCH 1/2] feat(auth): add RFC 6750 error to the /mcp 401 WWW-Authenticate header When a bearer token is presented but invalid/expired, include error="invalid_token" (+ error_description) in the WWW-Authenticate challenge, in addition to the RFC 9728 resource_metadata pointer, so MCP clients can distinguish "expired -> re-authorize" from "no token" and prompt an inline reconnect rather than sending the user to settings. A request with no token still gets a bare challenge (RFC 6750: omit the error code when no credentials were sent). The header is already exposed cross-origin via the /mcp CORS layer, so browser-based clients can read it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym --- src/auth.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index c59d755..c3dff36 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -888,6 +888,7 @@ pub async fn require_token(State(store): State, mut request: Request< .and_then(|h| h.strip_prefix("Bearer ")) .map(str::to_owned); + let had_token = token.is_some(); let session = match token { Some(t) => store.session_for_token(&t).await, None => None, @@ -900,10 +901,17 @@ pub async fn require_token(State(store): State, mut request: Request< next.run(request).await } None => { - let challenge = format!( - "Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource\"", - base_url() - ); + // RFC 6750 §3 / RFC 9728: point clients at the resource metadata, and + // — when a token WAS presented but is invalid/expired — carry + // `error="invalid_token"` in the header so the client can tell "expired, + // re-authorize" from "no token". A missing token gets a bare challenge + // (RFC 6750: don't include an error code when no credentials were sent). + let meta = format!("resource_metadata=\"{}/.well-known/oauth-protected-resource\"", base_url()); + let challenge = if had_token { + format!("Bearer error=\"invalid_token\", error_description=\"The access token is invalid or expired\", {meta}") + } else { + format!("Bearer {meta}") + }; ( StatusCode::UNAUTHORIZED, [(axum::http::header::WWW_AUTHENTICATE, challenge)], From de26d6cc4a661562a52e5b2bde73f31429bd0c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:19:54 +0000 Subject: [PATCH 2/2] fix(auth): case-insensitive Bearer scheme + test the 401 challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #30 review: - Parse the Authorization scheme case-insensitively (RFC 7235 §2.1), so `Authorization: bearer ` is recognized as a token (previously a case-sensitive strip_prefix("Bearer ") treated it as no token, which also mis-set had_token and dropped the error="invalid_token" challenge). - Extract the WWW-Authenticate challenge into a pure `bearer_challenge` helper and unit-test both branches (bare vs error="invalid_token"), to lock in the RFC 6750/9728 header formatting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym --- src/auth.rs | 64 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index c3dff36..1298358 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -881,12 +881,16 @@ pub struct AuthedSession { } pub async fn require_token(State(store): State, mut request: Request, next: Next) -> Response { + // The `Bearer` auth-scheme is case-insensitive (RFC 7235 §2.1), so match it + // that way — an `Authorization: bearer ` must be recognized too. let token = request .headers() .get("Authorization") .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) - .map(str::to_owned); + .and_then(|h| { + let (scheme, rest) = h.split_once(' ')?; + scheme.eq_ignore_ascii_case("Bearer").then(|| rest.trim().to_owned()) + }); let had_token = token.is_some(); let session = match token { @@ -900,25 +904,27 @@ pub async fn require_token(State(store): State, mut request: Request< request.extensions_mut().insert(AuthedSession { session_id }); next.run(request).await } - None => { - // RFC 6750 §3 / RFC 9728: point clients at the resource metadata, and - // — when a token WAS presented but is invalid/expired — carry - // `error="invalid_token"` in the header so the client can tell "expired, - // re-authorize" from "no token". A missing token gets a bare challenge - // (RFC 6750: don't include an error code when no credentials were sent). - let meta = format!("resource_metadata=\"{}/.well-known/oauth-protected-resource\"", base_url()); - let challenge = if had_token { - format!("Bearer error=\"invalid_token\", error_description=\"The access token is invalid or expired\", {meta}") - } else { - format!("Bearer {meta}") - }; - ( - StatusCode::UNAUTHORIZED, - [(axum::http::header::WWW_AUTHENTICATE, challenge)], - Json(json!({ "error": "invalid_token" })), - ) - .into_response() - } + None => ( + StatusCode::UNAUTHORIZED, + [(axum::http::header::WWW_AUTHENTICATE, bearer_challenge(had_token))], + Json(json!({ "error": "invalid_token" })), + ) + .into_response(), + } +} + +/// Build the `WWW-Authenticate` challenge for a 401 on `/mcp`. Always points +/// clients at the resource metadata (RFC 9728); when a token WAS presented but is +/// invalid/expired (`had_token`), also carries `error="invalid_token"` (RFC 6750 +/// §3) so the client can tell "expired → re-authorize" from "no token" and prompt +/// an inline reconnect. A missing token gets a bare challenge (RFC 6750: omit the +/// error code when no credentials were sent). +fn bearer_challenge(had_token: bool) -> String { + let meta = format!("resource_metadata=\"{}/.well-known/oauth-protected-resource\"", base_url()); + if had_token { + format!("Bearer error=\"invalid_token\", error_description=\"The access token is invalid or expired\", {meta}") + } else { + format!("Bearer {meta}") } } @@ -1003,6 +1009,22 @@ mod tests { assert!(!is_loopback_redirect("https://evil.com/cb")); } + /// RFC 6750 §3 / 9728: the `error` code appears only when a token was + /// presented; the resource_metadata pointer is always present. + #[test] + fn bearer_challenge_carries_error_only_for_presented_tokens() { + let with_token = super::bearer_challenge(true); + assert!(with_token.starts_with("Bearer ")); + assert!(with_token.contains("error=\"invalid_token\"")); + assert!(with_token.contains("error_description=")); + assert!(with_token.contains("resource_metadata=")); + + let no_token = super::bearer_challenge(false); + assert!(no_token.starts_with("Bearer ")); + assert!(!no_token.contains("error="), "a bare challenge must omit the error code: {no_token}"); + assert!(no_token.contains("resource_metadata=")); + } + #[test] fn build_redirect_encodes_code_and_state() { let r = build_redirect("https://claude.ai/cb", "mcp-code-1", "abc/def");