Skip to content

Commit 6395911

Browse files
ilblackdragonclaude
andcommitted
fix: address PR #1158 review feedback
- Remove 403 from auth-required detection (403 = forbidden, not unauthenticated; re-triggering OAuth on 403 creates a loop) - Compute msg.to_ascii_lowercase() once in send_request retry guard - Fix discover_via_401 doc comment: GitHub doesn't send WWW-Authenticate on 400; discovery falls through to RFC 9728 strategy 2/3 - Update error message from "Expected 401" to "Expected 401 or 400" - Strengthen E2E 400 test assertion: check auth_url/awaiting_token fields instead of loose message string matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6991ce3 commit 6395911

4 files changed

Lines changed: 23 additions & 39 deletions

File tree

src/extensions/manager.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2837,10 +2837,8 @@ impl ExtensionManager {
28372837
let msg_lower = msg.to_ascii_lowercase();
28382838
if msg_lower.contains("requires authentication")
28392839
|| msg.contains("401")
2840-
|| msg.contains("403")
28412840
|| (msg.contains("400")
2842-
&& (msg_lower.contains("authorization")
2843-
|| msg_lower.contains("authenticate")))
2841+
&& (msg_lower.contains("authorization") || msg_lower.contains("authenticate")))
28442842
{
28452843
ExtensionError::AuthRequired
28462844
} else {

src/tools/mcp/auth.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,10 @@ async fn fetch_resource_metadata(url: &str) -> Result<ProtectedResourceMetadata,
409409

410410
/// Try to discover OAuth metadata via 401 challenge response.
411411
///
412-
/// Some servers (e.g. GitHub MCP) may return 400 instead of 401 but still
413-
/// include a `WWW-Authenticate` header with discovery metadata, so we also
414-
/// check for the header on 400 responses.
412+
/// Also accepts 400 responses, since some servers return 400 for
413+
/// unauthenticated requests. In practice the 400 path rarely yields a
414+
/// `WWW-Authenticate` header (GitHub's MCP does not), so discovery
415+
/// typically falls through to strategy 2 (RFC 9728) or 3 (direct).
415416
async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadata, AuthError> {
416417
validate_url_safe(server_url).await?;
417418

@@ -435,7 +436,7 @@ async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadat
435436
// In both cases, look for WWW-Authenticate header with discovery metadata.
436437
if status != 401 && status != 400 {
437438
return Err(AuthError::DiscoveryFailed(format!(
438-
"Expected 401, got {}",
439+
"Expected 401 or 400, got {}",
439440
response.status()
440441
)));
441442
}
@@ -445,10 +446,7 @@ async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadat
445446
.get("WWW-Authenticate")
446447
.and_then(|v| v.to_str().ok())
447448
.ok_or_else(|| {
448-
AuthError::DiscoveryFailed(format!(
449-
"No WWW-Authenticate header in {} response",
450-
status
451-
))
449+
AuthError::DiscoveryFailed(format!("No WWW-Authenticate header in {} response", status))
452450
})?;
453451

454452
let resource_metadata_url = parse_resource_metadata_url(www_auth).ok_or_else(|| {

src/tools/mcp/client.rs

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,10 @@ impl McpClient {
302302
Err(ToolError::ExternalService(ref msg))
303303
if msg.contains("401")
304304
|| msg.contains("Unauthorized")
305-
|| (msg.contains("400")
306-
&& (msg.to_ascii_lowercase().contains("authorization")
307-
|| msg.to_ascii_lowercase().contains("authenticate"))) =>
305+
|| (msg.contains("400") && {
306+
let lower = msg.to_ascii_lowercase();
307+
lower.contains("authorization") || lower.contains("authenticate")
308+
}) =>
308309
{
309310
if attempt == 0
310311
&& let Some(ref secrets) = self.secrets
@@ -984,9 +985,7 @@ mod tests {
984985
// "Authorization header is badly formatted" in this case).
985986
#[tokio::test]
986987
async fn test_build_headers_skips_empty_token() {
987-
use crate::secrets::{
988-
CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef,
989-
};
988+
use crate::secrets::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef};
990989
use uuid::Uuid;
991990

992991
// In-memory secrets store that returns a whitespace-only string for the token.
@@ -1000,11 +999,7 @@ mod tests {
1000999
) -> Result<Secret, SecretError> {
10011000
unimplemented!()
10021001
}
1003-
async fn get(
1004-
&self,
1005-
_user_id: &str,
1006-
_name: &str,
1007-
) -> Result<Secret, SecretError> {
1002+
async fn get(&self, _user_id: &str, _name: &str) -> Result<Secret, SecretError> {
10081003
unimplemented!()
10091004
}
10101005
async fn get_decrypted(
@@ -1041,8 +1036,7 @@ mod tests {
10411036
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
10421037
Arc::new(EmptyTokenStore);
10431038

1044-
let client =
1045-
McpClient::new_authenticated(config, session_manager, secrets, "test-user");
1039+
let client = McpClient::new_authenticated(config, session_manager, secrets, "test-user");
10461040

10471041
let headers = client.build_request_headers().await.unwrap();
10481042
assert!(
@@ -1056,9 +1050,7 @@ mod tests {
10561050
// before being used in the Authorization header.
10571051
#[tokio::test]
10581052
async fn test_build_headers_trims_token() {
1059-
use crate::secrets::{
1060-
CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef,
1061-
};
1053+
use crate::secrets::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef};
10621054
use uuid::Uuid;
10631055

10641056
struct PaddedTokenStore;
@@ -1071,11 +1063,7 @@ mod tests {
10711063
) -> Result<Secret, SecretError> {
10721064
unimplemented!()
10731065
}
1074-
async fn get(
1075-
&self,
1076-
_user_id: &str,
1077-
_name: &str,
1078-
) -> Result<Secret, SecretError> {
1066+
async fn get(&self, _user_id: &str, _name: &str) -> Result<Secret, SecretError> {
10791067
unimplemented!()
10801068
}
10811069
async fn get_decrypted(
@@ -1112,8 +1100,7 @@ mod tests {
11121100
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
11131101
Arc::new(PaddedTokenStore);
11141102

1115-
let client =
1116-
McpClient::new_authenticated(config, session_manager, secrets, "test-user");
1103+
let client = McpClient::new_authenticated(config, session_manager, secrets, "test-user");
11171104

11181105
let headers = client.build_request_headers().await.unwrap();
11191106
assert_eq!(

tests/e2e/scenarios/test_mcp_auth_flow.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,12 @@ async def test_mcp_400_activate_triggers_auth(ironclaw_server, mock_llm_server):
242242
)
243243
data = r.json()
244244

245-
# Should NOT return a raw activation error like "400 Bad Request"
246-
# Instead should return auth_url or awaiting_token
247-
message = data.get("message", "")
248-
assert "400" not in message or "auth" in message.lower(), (
249-
f"400 error should trigger auth flow, not raw error: {data}"
245+
# The 400 should be treated as auth-required, returning an auth_url
246+
# or awaiting_token — not a raw "400 Bad Request" activation error.
247+
auth_url = data.get("auth_url")
248+
awaiting_token = data.get("awaiting_token")
249+
assert auth_url is not None or awaiting_token, (
250+
f"400 auth error should trigger auth flow (auth_url or awaiting_token), got: {data}"
250251
)
251252

252253

0 commit comments

Comments
 (0)