1use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11use reqwest::header::{ACCEPT, WWW_AUTHENTICATE};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value as JsonValue};
14use url::Url;
15
16pub const OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH: &str = "/.well-known/oauth-protected-resource";
17pub const OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH: &str =
18 "/.well-known/oauth-authorization-server";
19pub const OIDC_CONFIGURATION_WELL_KNOWN_PATH: &str = "/.well-known/openid-configuration";
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct WwwAuthenticateChallenge {
23 pub scheme: String,
24 pub params: BTreeMap<String, String>,
25}
26
27impl WwwAuthenticateChallenge {
28 pub fn bearer_resource_metadata(&self) -> Option<&str> {
29 self.scheme
30 .eq_ignore_ascii_case("bearer")
31 .then(|| self.params.get("resource_metadata").map(String::as_str))
32 .flatten()
33 }
34
35 pub fn bearer_scope(&self) -> Option<&str> {
36 self.scheme
37 .eq_ignore_ascii_case("bearer")
38 .then(|| self.params.get("scope").map(String::as_str))
39 .flatten()
40 }
41}
42
43#[derive(Clone, Debug, Default, Deserialize, Serialize)]
44pub struct OAuthProtectedResourceMetadata {
45 #[serde(default)]
46 pub resource: Option<String>,
47 #[serde(default)]
48 pub authorization_servers: Vec<String>,
49 #[serde(default)]
50 pub scopes_supported: Vec<String>,
51 #[serde(default)]
52 pub bearer_methods_supported: Vec<String>,
53 #[serde(flatten)]
54 pub extra: BTreeMap<String, JsonValue>,
55}
56
57#[derive(Clone, Debug, Deserialize, Serialize)]
58pub struct OAuthAuthorizationServerMetadata {
59 pub issuer: String,
60 pub authorization_endpoint: String,
61 pub token_endpoint: String,
62 #[serde(default)]
63 pub registration_endpoint: Option<String>,
64 #[serde(default)]
65 pub token_endpoint_auth_methods_supported: Vec<String>,
66 #[serde(default)]
67 pub code_challenge_methods_supported: Vec<String>,
68 #[serde(default)]
69 pub scopes_supported: Vec<String>,
70 #[serde(default)]
71 pub client_id_metadata_document_supported: bool,
72 #[serde(default)]
73 pub authorization_response_iss_parameter_supported: bool,
74 #[serde(flatten)]
75 pub extra: BTreeMap<String, JsonValue>,
76}
77
78#[derive(Clone, Debug, Default, Deserialize, Serialize)]
79pub struct OAuthDynamicClientRegistrationResponse {
80 pub client_id: String,
81 #[serde(default)]
82 pub client_secret: Option<String>,
83 #[serde(default)]
84 pub token_endpoint_auth_method: Option<String>,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "snake_case")]
89pub enum OAuthAuthorizationServerMetadataKind {
90 OAuthAuthorizationServer,
91 OpenIdConfiguration,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct OAuthAuthorizationServerMetadataCandidate {
96 pub url: Url,
97 pub kind: OAuthAuthorizationServerMetadataKind,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "snake_case")]
102pub enum OAuthClientRegistrationMode {
103 PreRegistered,
104 ClientIdMetadataDocument,
105 DynamicClientRegistration,
106 Manual,
107}
108
109impl OAuthClientRegistrationMode {
110 pub fn as_str(self) -> &'static str {
111 match self {
112 Self::PreRegistered => "pre_registered",
113 Self::ClientIdMetadataDocument => "client_id_metadata_document",
114 Self::DynamicClientRegistration => "dynamic_client_registration",
115 Self::Manual => "manual",
116 }
117 }
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "lowercase")]
122pub enum OAuthApplicationType {
123 Native,
124 Web,
125}
126
127impl OAuthApplicationType {
128 pub fn as_str(self) -> &'static str {
129 match self {
130 Self::Native => "native",
131 Self::Web => "web",
132 }
133 }
134}
135
136#[derive(Clone, Debug, Default)]
137pub struct OAuthClientRegistrationOptions<'a> {
138 pub client_id: Option<&'a str>,
139 pub client_secret: Option<&'a str>,
140 pub client_id_metadata_document_url: Option<&'a str>,
141}
142
143#[derive(Clone, Debug)]
144pub struct McpOAuthDiscovery {
145 pub protected_resource_metadata_url: Url,
146 pub protected_resource_metadata: OAuthProtectedResourceMetadata,
147 pub authorization_server_issuer: String,
148 pub authorization_server_metadata_url: Url,
149 pub authorization_server_metadata_kind: OAuthAuthorizationServerMetadataKind,
150 pub authorization_server_metadata: OAuthAuthorizationServerMetadata,
151 pub challenge: Option<WwwAuthenticateChallenge>,
152 pub scopes: Vec<String>,
153}
154
155#[derive(Debug)]
156pub enum McpOAuthDiscoveryError {
157 InvalidResourceUrl(String),
158 InvalidResourceMetadataUrl(String),
159 InvalidAuthorizationServerUrl { issuer: String, error: String },
160 ProtectedResourceMetadataNotFound,
161 MissingAuthorizationServer,
162 AuthorizationServerMetadataNotFound { issuer: String },
163 AuthorizationServerIssuerMismatch { expected: String, actual: String },
164 AuthorizationServerIssuerMissing { expected: String },
165 Json { url: String, error: String },
166}
167
168impl fmt::Display for McpOAuthDiscoveryError {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 match self {
171 Self::InvalidResourceUrl(error) => write!(f, "invalid MCP resource URL: {error}"),
172 Self::InvalidResourceMetadataUrl(error) => {
173 write!(f, "invalid resource_metadata URL in WWW-Authenticate: {error}")
174 }
175 Self::InvalidAuthorizationServerUrl { issuer, error } => {
176 write!(f, "invalid authorization server URL '{issuer}': {error}")
177 }
178 Self::ProtectedResourceMetadataNotFound => {
179 write!(f, "OAuth protected resource metadata not found")
180 }
181 Self::MissingAuthorizationServer => write!(
182 f,
183 "OAuth protected resource metadata did not advertise an authorization server"
184 ),
185 Self::AuthorizationServerMetadataNotFound { issuer } => {
186 write!(f, "authorization server metadata not found for issuer '{issuer}'")
187 }
188 Self::AuthorizationServerIssuerMismatch { expected, actual } => write!(
189 f,
190 "authorization server metadata issuer mismatch: expected '{expected}', got '{actual}'"
191 ),
192 Self::AuthorizationServerIssuerMissing { expected } => write!(
193 f,
194 "authorization server metadata for '{expected}' did not include an issuer"
195 ),
196 Self::Json { url, error } => write!(f, "failed to parse {url}: {error}"),
197 }
198 }
199}
200
201impl std::error::Error for McpOAuthDiscoveryError {}
202
203pub fn parse_www_authenticate(header: &str) -> Vec<WwwAuthenticateChallenge> {
204 let mut challenges = Vec::<WwwAuthenticateChallenge>::new();
205 let mut current: Option<WwwAuthenticateChallenge> = None;
206
207 for segment in split_challenge_segments(header) {
208 let segment = segment.trim();
209 if segment.is_empty() {
210 continue;
211 }
212 let (first, rest) = split_first_token(segment);
213 let starts_challenge = !first.contains('=');
214 if starts_challenge {
215 if let Some(challenge) = current.take() {
216 challenges.push(challenge);
217 }
218 let mut challenge = WwwAuthenticateChallenge {
219 scheme: first.to_string(),
220 params: BTreeMap::new(),
221 };
222 if !rest.trim().is_empty() {
223 parse_auth_param(rest.trim(), &mut challenge.params);
224 }
225 current = Some(challenge);
226 } else if let Some(challenge) = current.as_mut() {
227 parse_auth_param(segment, &mut challenge.params);
228 }
229 }
230
231 if let Some(challenge) = current {
232 challenges.push(challenge);
233 }
234 challenges
235}
236
237pub fn parse_www_authenticate_headers<'a>(
238 headers: impl IntoIterator<Item = &'a str>,
239) -> Vec<WwwAuthenticateChallenge> {
240 headers
241 .into_iter()
242 .flat_map(parse_www_authenticate)
243 .collect()
244}
245
246pub fn bearer_challenge_from_headers<'a>(
247 headers: impl IntoIterator<Item = &'a str>,
248) -> Option<WwwAuthenticateChallenge> {
249 let mut first_bearer = None;
250 for challenge in parse_www_authenticate_headers(headers) {
251 if !challenge.scheme.eq_ignore_ascii_case("bearer") {
252 continue;
253 }
254 if challenge.bearer_resource_metadata().is_some() {
255 return Some(challenge);
256 }
257 first_bearer.get_or_insert(challenge);
258 }
259 first_bearer
260}
261
262pub fn protected_resource_metadata_candidates(resource_url: &Url) -> Vec<Url> {
263 let mut urls = Vec::new();
264 let path = resource_url
265 .path()
266 .trim_start_matches('/')
267 .trim_end_matches('/');
268 if !path.is_empty() {
269 let mut url = resource_url.clone();
270 url.set_path(&format!(
271 "{OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH}/{path}"
272 ));
273 url.set_query(None);
274 url.set_fragment(None);
275 urls.push(url);
276 }
277 let mut root = resource_url.clone();
278 root.set_path(OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH);
279 root.set_query(None);
280 root.set_fragment(None);
281 urls.push(root);
282 urls
283}
284
285pub fn protected_resource_metadata_path(mcp_path: &str) -> String {
286 let mcp_path = normalize_path(mcp_path);
287 if mcp_path == "/" {
288 OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH.to_string()
289 } else {
290 format!("{OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH}{mcp_path}")
291 }
292}
293
294pub fn authorization_server_metadata_candidates(
295 auth_server_url: &Url,
296) -> Vec<OAuthAuthorizationServerMetadataCandidate> {
297 let mut urls = Vec::new();
298 let path = auth_server_url.path();
299 let has_path = !path.is_empty() && path != "/";
300 if has_path {
301 let trimmed = path.trim_start_matches('/');
302
303 let mut oauth = auth_server_url.clone();
304 oauth.set_path(&format!(
305 "{OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH}/{trimmed}"
306 ));
307 oauth.set_query(None);
308 oauth.set_fragment(None);
309 urls.push(OAuthAuthorizationServerMetadataCandidate {
310 url: oauth,
311 kind: OAuthAuthorizationServerMetadataKind::OAuthAuthorizationServer,
312 });
313
314 let mut oidc_inserted = auth_server_url.clone();
315 oidc_inserted.set_path(&format!("{OIDC_CONFIGURATION_WELL_KNOWN_PATH}/{trimmed}"));
316 oidc_inserted.set_query(None);
317 oidc_inserted.set_fragment(None);
318 urls.push(OAuthAuthorizationServerMetadataCandidate {
319 url: oidc_inserted,
320 kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
321 });
322
323 let mut oidc_appended = auth_server_url.clone();
324 let base = path.trim_end_matches('/');
325 oidc_appended.set_path(&format!("{base}{OIDC_CONFIGURATION_WELL_KNOWN_PATH}"));
326 oidc_appended.set_query(None);
327 oidc_appended.set_fragment(None);
328 urls.push(OAuthAuthorizationServerMetadataCandidate {
329 url: oidc_appended,
330 kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
331 });
332 return urls;
333 }
334
335 let mut oauth = auth_server_url.clone();
336 oauth.set_path(OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH);
337 oauth.set_query(None);
338 oauth.set_fragment(None);
339 urls.push(OAuthAuthorizationServerMetadataCandidate {
340 url: oauth,
341 kind: OAuthAuthorizationServerMetadataKind::OAuthAuthorizationServer,
342 });
343
344 let mut oidc = auth_server_url.clone();
345 oidc.set_path(OIDC_CONFIGURATION_WELL_KNOWN_PATH);
346 oidc.set_query(None);
347 oidc.set_fragment(None);
348 urls.push(OAuthAuthorizationServerMetadataCandidate {
349 url: oidc,
350 kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
351 });
352 urls
353}
354
355pub async fn discover_mcp_oauth(
356 client: &reqwest::Client,
357 resource: &str,
358) -> Result<McpOAuthDiscovery, McpOAuthDiscoveryError> {
359 let resource_url = Url::parse(resource)
360 .map_err(|error| McpOAuthDiscoveryError::InvalidResourceUrl(error.to_string()))?;
361 discover_mcp_oauth_from_url(client, &resource_url).await
362}
363
364pub async fn discover_mcp_oauth_from_url(
365 client: &reqwest::Client,
366 resource_url: &Url,
367) -> Result<McpOAuthDiscovery, McpOAuthDiscoveryError> {
368 let challenge = fetch_resource_challenge(client, resource_url).await;
369 let challenged_metadata_url = challenge
370 .as_ref()
371 .and_then(WwwAuthenticateChallenge::bearer_resource_metadata)
372 .map(|url| {
373 Url::parse(url).map_err(|error| {
374 McpOAuthDiscoveryError::InvalidResourceMetadataUrl(error.to_string())
375 })
376 })
377 .transpose()?;
378
379 let metadata_candidates = challenged_metadata_url
380 .into_iter()
381 .chain(protected_resource_metadata_candidates(resource_url))
382 .collect::<Vec<_>>();
383 let (protected_resource_metadata_url, protected_resource_metadata) =
384 fetch_first_json::<OAuthProtectedResourceMetadata>(client, &metadata_candidates)
385 .await?
386 .ok_or(McpOAuthDiscoveryError::ProtectedResourceMetadataNotFound)?;
387 let authorization_server_issuer = protected_resource_metadata
388 .authorization_servers
389 .first()
390 .cloned()
391 .ok_or(McpOAuthDiscoveryError::MissingAuthorizationServer)?;
392 let auth_server_url = Url::parse(&authorization_server_issuer).map_err(|error| {
393 McpOAuthDiscoveryError::InvalidAuthorizationServerUrl {
394 issuer: authorization_server_issuer.clone(),
395 error: error.to_string(),
396 }
397 })?;
398 let (authorization_server_metadata_url, authorization_server_metadata_kind, metadata) =
399 fetch_authorization_server_metadata(client, &authorization_server_issuer, &auth_server_url)
400 .await?;
401 let scopes = select_oauth_scopes(
402 challenge
403 .as_ref()
404 .and_then(WwwAuthenticateChallenge::bearer_scope),
405 &protected_resource_metadata.scopes_supported,
406 );
407 Ok(McpOAuthDiscovery {
408 protected_resource_metadata_url,
409 protected_resource_metadata,
410 authorization_server_issuer,
411 authorization_server_metadata_url,
412 authorization_server_metadata_kind,
413 authorization_server_metadata: metadata,
414 challenge,
415 scopes,
416 })
417}
418
419pub async fn fetch_authorization_server_metadata(
420 client: &reqwest::Client,
421 expected_issuer: &str,
422 auth_server_url: &Url,
423) -> Result<
424 (
425 Url,
426 OAuthAuthorizationServerMetadataKind,
427 OAuthAuthorizationServerMetadata,
428 ),
429 McpOAuthDiscoveryError,
430> {
431 let candidates = authorization_server_metadata_candidates(auth_server_url);
432 for candidate in candidates {
433 let Some(metadata) =
434 fetch_json::<OAuthAuthorizationServerMetadata>(client, &candidate.url).await?
435 else {
436 continue;
437 };
438 validate_authorization_server_issuer(expected_issuer, &metadata)?;
439 return Ok((candidate.url, candidate.kind, metadata));
440 }
441 Err(
442 McpOAuthDiscoveryError::AuthorizationServerMetadataNotFound {
443 issuer: expected_issuer.to_string(),
444 },
445 )
446}
447
448pub fn validate_authorization_server_issuer(
449 expected_issuer: &str,
450 metadata: &OAuthAuthorizationServerMetadata,
451) -> Result<(), McpOAuthDiscoveryError> {
452 if metadata.issuer.is_empty() {
453 return Err(McpOAuthDiscoveryError::AuthorizationServerIssuerMissing {
454 expected: expected_issuer.to_string(),
455 });
456 }
457 if metadata.issuer != expected_issuer {
458 return Err(McpOAuthDiscoveryError::AuthorizationServerIssuerMismatch {
459 expected: expected_issuer.to_string(),
460 actual: metadata.issuer.clone(),
461 });
462 }
463 Ok(())
464}
465
466pub fn validate_authorization_response_issuer(
467 metadata: &OAuthAuthorizationServerMetadata,
468 response_issuer: Option<&str>,
469) -> Result<(), String> {
470 match (
471 metadata.authorization_response_iss_parameter_supported,
472 response_issuer,
473 ) {
474 (true, Some(actual)) if actual == metadata.issuer => Ok(()),
475 (true, Some(actual)) => Err(format!(
476 "authorization response issuer mismatch: expected '{}', got '{}'",
477 metadata.issuer, actual
478 )),
479 (true, None) => Err(
480 "authorization response did not include required RFC 9207 iss parameter".to_string(),
481 ),
482 (false, Some(actual)) if actual == metadata.issuer => Ok(()),
483 (false, Some(actual)) => Err(format!(
484 "authorization response issuer mismatch: expected '{}', got '{}'",
485 metadata.issuer, actual
486 )),
487 (false, None) => Ok(()),
488 }
489}
490
491pub fn validate_issuer_binding(stored_issuer: &str, current_issuer: &str) -> Result<(), String> {
492 if stored_issuer == current_issuer {
493 Ok(())
494 } else {
495 Err(format!(
496 "stored OAuth credentials are bound to issuer '{stored_issuer}', but the MCP resource now advertises '{current_issuer}'"
497 ))
498 }
499}
500
501pub fn select_oauth_scopes(
502 challenge_scope: Option<&str>,
503 scopes_supported: &[String],
504) -> Vec<String> {
505 let challenged = split_scope_value(challenge_scope);
506 if challenged.is_empty() {
507 dedupe_scopes(scopes_supported.iter().map(String::as_str))
508 } else {
509 challenged
510 }
511}
512
513pub fn accumulate_oauth_scopes<'a>(
514 existing: impl IntoIterator<Item = &'a str>,
515 challenged: impl IntoIterator<Item = &'a str>,
516) -> Vec<String> {
517 dedupe_scopes(existing.into_iter().chain(challenged))
518}
519
520pub fn split_scope_value(value: Option<&str>) -> Vec<String> {
521 dedupe_scopes(
522 value
523 .unwrap_or_default()
524 .split_whitespace()
525 .map(str::trim)
526 .filter(|scope| !scope.is_empty()),
527 )
528}
529
530pub fn select_client_registration_mode(
531 metadata: &OAuthAuthorizationServerMetadata,
532 options: OAuthClientRegistrationOptions<'_>,
533) -> OAuthClientRegistrationMode {
534 if let Some(client_id) = options.client_id {
535 if options.client_secret.is_none() && is_client_id_metadata_document_url(client_id) {
536 return OAuthClientRegistrationMode::ClientIdMetadataDocument;
537 }
538 return OAuthClientRegistrationMode::PreRegistered;
539 }
540 if options
541 .client_id_metadata_document_url
542 .is_some_and(is_client_id_metadata_document_url)
543 && metadata.client_id_metadata_document_supported
544 {
545 return OAuthClientRegistrationMode::ClientIdMetadataDocument;
546 }
547 if metadata.registration_endpoint.is_some() {
548 return OAuthClientRegistrationMode::DynamicClientRegistration;
549 }
550 OAuthClientRegistrationMode::Manual
551}
552
553pub fn is_client_id_metadata_document_url(client_id: &str) -> bool {
554 Url::parse(client_id)
555 .ok()
556 .filter(|url| url.scheme() == "https")
557 .and_then(|url| {
558 let path = url.path().trim_matches('/');
559 (!path.is_empty()).then_some(())
560 })
561 .is_some()
562}
563
564pub fn ensure_pkce_s256_supported(
565 metadata: &OAuthAuthorizationServerMetadata,
566) -> Result<(), String> {
567 let methods = &metadata.code_challenge_methods_supported;
568 if methods.is_empty() || methods.iter().any(|method| method == "S256") {
569 return Ok(());
570 }
571 Err("Authorization server does not advertise PKCE S256 support".to_string())
572}
573
574pub fn determine_token_endpoint_auth_method(
575 metadata: &OAuthAuthorizationServerMetadata,
576 client_secret: Option<&str>,
577) -> Result<String, String> {
578 let methods = &metadata.token_endpoint_auth_methods_supported;
579 if client_secret.is_some() {
580 if methods.is_empty() || methods.iter().any(|method| method == "client_secret_post") {
581 return Ok("client_secret_post".to_string());
582 }
583 if methods.iter().any(|method| method == "client_secret_basic") {
584 return Ok("client_secret_basic".to_string());
585 }
586 return Err(
587 "Authorization server does not support client_secret_post or client_secret_basic"
588 .to_string(),
589 );
590 }
591
592 if methods.is_empty() || methods.iter().any(|method| method == "none") {
593 return Ok("none".to_string());
594 }
595 Err("Authorization server requires client authentication. Supply --client-secret or configure a registered client.".to_string())
596}
597
598pub fn validate_token_endpoint_auth_method(method: &str) -> Result<(), String> {
599 match method {
600 "none" | "client_secret_post" | "client_secret_basic" => Ok(()),
601 other => Err(format!(
602 "unsupported token auth method '{other}'; expected none, client_secret_post, or client_secret_basic"
603 )),
604 }
605}
606
607pub fn application_type_for_redirect_uris<'a>(
608 redirect_uris: impl IntoIterator<Item = &'a str>,
609) -> OAuthApplicationType {
610 if redirect_uris.into_iter().all(redirect_uri_is_native) {
611 OAuthApplicationType::Native
612 } else {
613 OAuthApplicationType::Web
614 }
615}
616
617pub fn dynamic_client_registration_body<'a>(
618 client_name: &str,
619 redirect_uris: impl IntoIterator<Item = &'a str>,
620 scopes: Option<&str>,
621) -> JsonValue {
622 let redirect_uris = redirect_uris
623 .into_iter()
624 .map(ToString::to_string)
625 .collect::<Vec<_>>();
626 let application_type =
627 application_type_for_redirect_uris(redirect_uris.iter().map(String::as_str));
628 let mut body = json!({
629 "client_name": client_name,
630 "redirect_uris": redirect_uris,
631 "grant_types": ["authorization_code", "refresh_token"],
632 "response_types": ["code"],
633 "token_endpoint_auth_method": "none",
634 "application_type": application_type.as_str(),
635 });
636 if let Some(scopes) = scopes.filter(|scopes| !scopes.trim().is_empty()) {
637 body["scope"] = json!(scopes);
638 }
639 body
640}
641
642pub fn bearer_challenge_value(
643 resource_metadata_url: &str,
644 scopes: &[String],
645 error: Option<BearerChallengeError<'_>>,
646) -> String {
647 let mut parts = vec![format!(
648 "resource_metadata=\"{}\"",
649 quote_auth_value(resource_metadata_url)
650 )];
651 if !scopes.is_empty() {
652 parts.push(format!("scope=\"{}\"", quote_auth_value(&scopes.join(" "))));
653 }
654 if let Some(error) = error {
655 parts.insert(0, format!("error=\"{}\"", quote_auth_value(error.code)));
656 if let Some(description) = error.description {
657 parts.push(format!(
658 "error_description=\"{}\"",
659 quote_auth_value(description)
660 ));
661 }
662 }
663 format!("Bearer {}", parts.join(", "))
664}
665
666#[derive(Clone, Copy, Debug, PartialEq, Eq)]
667pub struct BearerChallengeError<'a> {
668 pub code: &'a str,
669 pub description: Option<&'a str>,
670}
671
672fn split_challenge_segments(header: &str) -> Vec<&str> {
673 let mut segments = Vec::new();
674 let mut start = 0;
675 let mut in_quote = false;
676 let mut escaped = false;
677 for (index, character) in header.char_indices() {
678 if escaped {
679 escaped = false;
680 continue;
681 }
682 match character {
683 '\\' if in_quote => escaped = true,
684 '"' => in_quote = !in_quote,
685 ',' if !in_quote => {
686 segments.push(&header[start..index]);
687 start = index + 1;
688 }
689 _ => {}
690 }
691 }
692 segments.push(&header[start..]);
693 segments
694}
695
696fn split_first_token(segment: &str) -> (&str, &str) {
697 let trimmed = segment.trim_start();
698 match trimmed.find(char::is_whitespace) {
699 Some(index) => (&trimmed[..index], &trimmed[index..]),
700 None => (trimmed, ""),
701 }
702}
703
704fn parse_auth_param(segment: &str, params: &mut BTreeMap<String, String>) {
705 let Some((key, raw_value)) = segment.split_once('=') else {
706 return;
707 };
708 let key = key.trim().to_ascii_lowercase();
709 if key.is_empty() {
710 return;
711 }
712 params.insert(key, parse_auth_value(raw_value.trim()));
713}
714
715fn parse_auth_value(raw_value: &str) -> String {
716 let Some(stripped) = raw_value
717 .strip_prefix('"')
718 .and_then(|value| value.strip_suffix('"'))
719 else {
720 return raw_value.trim().to_string();
721 };
722 let mut value = String::new();
723 let mut chars = stripped.chars();
724 while let Some(character) = chars.next() {
725 if character == '\\' {
726 if let Some(escaped) = chars.next() {
727 value.push(escaped);
728 }
729 } else {
730 value.push(character);
731 }
732 }
733 value
734}
735
736async fn fetch_resource_challenge(
737 client: &reqwest::Client,
738 resource_url: &Url,
739) -> Option<WwwAuthenticateChallenge> {
740 let response = client
741 .get(resource_url.clone())
742 .header(ACCEPT, "application/json")
743 .send()
744 .await
745 .ok()?;
746 let header_values = response
747 .headers()
748 .get_all(WWW_AUTHENTICATE)
749 .iter()
750 .filter_map(|value| value.to_str().ok())
751 .collect::<Vec<_>>();
752 bearer_challenge_from_headers(header_values)
753}
754
755async fn fetch_first_json<T: for<'de> Deserialize<'de>>(
756 client: &reqwest::Client,
757 candidates: &[Url],
758) -> Result<Option<(Url, T)>, McpOAuthDiscoveryError> {
759 for candidate in candidates {
760 if let Some(parsed) = fetch_json::<T>(client, candidate).await? {
761 return Ok(Some((candidate.clone(), parsed)));
762 }
763 }
764 Ok(None)
765}
766
767async fn fetch_json<T: for<'de> Deserialize<'de>>(
768 client: &reqwest::Client,
769 url: &Url,
770) -> Result<Option<T>, McpOAuthDiscoveryError> {
771 let response = match client.get(url.clone()).send().await {
772 Ok(response) => response,
773 Err(_) => return Ok(None),
774 };
775 if !response.status().is_success() {
776 return Ok(None);
777 }
778 response
779 .json::<T>()
780 .await
781 .map(Some)
782 .map_err(|error| McpOAuthDiscoveryError::Json {
783 url: url.to_string(),
784 error: error.to_string(),
785 })
786}
787
788fn dedupe_scopes<'a>(scopes: impl IntoIterator<Item = &'a str>) -> Vec<String> {
789 let mut seen = BTreeSet::new();
790 let mut ordered = Vec::new();
791 for scope in scopes {
792 let scope = scope.trim();
793 if !scope.is_empty() && seen.insert(scope.to_string()) {
794 ordered.push(scope.to_string());
795 }
796 }
797 ordered
798}
799
800fn redirect_uri_is_native(redirect_uri: &str) -> bool {
801 let Ok(url) = Url::parse(redirect_uri) else {
802 return false;
803 };
804 if url.scheme() != "http" && url.scheme() != "https" {
805 return true;
806 }
807 matches!(
808 url.host_str(),
809 Some("127.0.0.1") | Some("localhost") | Some("::1") | Some("[::1]")
810 )
811}
812
813fn normalize_path(path: &str) -> String {
814 let trimmed = path.trim();
815 if trimmed.is_empty() || trimmed == "/" {
816 "/".to_string()
817 } else if trimmed.starts_with('/') {
818 trimmed.to_string()
819 } else {
820 format!("/{trimmed}")
821 }
822}
823
824fn quote_auth_value(value: &str) -> String {
825 value.replace('\\', "\\\\").replace('"', "\\\"")
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 fn metadata(
833 issuer: &str,
834 registration_endpoint: Option<&str>,
835 ) -> OAuthAuthorizationServerMetadata {
836 OAuthAuthorizationServerMetadata {
837 issuer: issuer.to_string(),
838 authorization_endpoint: format!("{issuer}/authorize"),
839 token_endpoint: format!("{issuer}/token"),
840 registration_endpoint: registration_endpoint.map(ToString::to_string),
841 token_endpoint_auth_methods_supported: vec!["none".to_string()],
842 code_challenge_methods_supported: vec!["S256".to_string()],
843 scopes_supported: Vec::new(),
844 client_id_metadata_document_supported: false,
845 authorization_response_iss_parameter_supported: false,
846 extra: BTreeMap::new(),
847 }
848 }
849
850 #[test]
851 fn parses_bearer_challenge_resource_metadata_and_scope() {
852 let challenges = parse_www_authenticate(
853 r#"Bearer realm="mcp", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read files:write""#,
854 );
855 assert_eq!(challenges.len(), 1);
856 let challenge = &challenges[0];
857 assert_eq!(
858 challenge.bearer_resource_metadata(),
859 Some("https://mcp.example/.well-known/oauth-protected-resource")
860 );
861 assert_eq!(
862 split_scope_value(challenge.bearer_scope()),
863 vec!["files:read", "files:write"]
864 );
865 }
866
867 #[test]
868 fn parses_multiple_www_authenticate_challenges() {
869 let challenge = bearer_challenge_from_headers([
870 r#"Basic realm="old""#,
871 r#"Bearer error="insufficient_scope", scope="admin", resource_metadata="https://mcp.example/meta""#,
872 ])
873 .expect("bearer challenge");
874 assert_eq!(
875 challenge.params.get("error").map(String::as_str),
876 Some("insufficient_scope")
877 );
878 assert_eq!(
879 challenge.bearer_resource_metadata(),
880 Some("https://mcp.example/meta")
881 );
882 }
883
884 #[test]
885 fn bearer_challenge_selection_prefers_resource_metadata() {
886 let challenge = bearer_challenge_from_headers([
887 r#"Bearer realm="old", Bearer resource_metadata="https://mcp.example/meta""#,
888 ])
889 .expect("bearer challenge");
890 assert_eq!(
891 challenge.bearer_resource_metadata(),
892 Some("https://mcp.example/meta")
893 );
894 }
895
896 #[test]
897 fn authorization_server_candidates_include_oidc_path_appending() {
898 let issuer = Url::parse("https://auth.example.com/tenant1").unwrap();
899 let candidates = authorization_server_metadata_candidates(&issuer);
900 let urls = candidates
901 .iter()
902 .map(|candidate| candidate.url.as_str())
903 .collect::<Vec<_>>();
904 assert_eq!(
905 urls,
906 vec![
907 "https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
908 "https://auth.example.com/.well-known/openid-configuration/tenant1",
909 "https://auth.example.com/tenant1/.well-known/openid-configuration",
910 ]
911 );
912 }
913
914 #[test]
915 fn validates_authorization_server_issuer_without_normalization() {
916 let mut metadata = metadata("https://auth.example.com", None);
917 validate_authorization_server_issuer("https://auth.example.com", &metadata).unwrap();
918 metadata.issuer = "https://auth.example.com/".to_string();
919 let err = validate_authorization_server_issuer("https://auth.example.com", &metadata)
920 .expect_err("issuer mismatch");
921 assert!(err.to_string().contains("issuer mismatch"));
922 }
923
924 #[test]
925 fn authorization_response_issuer_validation_follows_rfc9207_advertisement() {
926 let mut metadata = metadata("https://auth.example.com", None);
927 assert!(validate_authorization_response_issuer(&metadata, None).is_ok());
928 assert!(
929 validate_authorization_response_issuer(&metadata, Some("https://other.example"))
930 .is_err()
931 );
932 metadata.authorization_response_iss_parameter_supported = true;
933 assert!(validate_authorization_response_issuer(&metadata, None).is_err());
934 assert!(validate_authorization_response_issuer(
935 &metadata,
936 Some("https://auth.example.com")
937 )
938 .is_ok());
939 }
940
941 #[test]
942 fn scope_selection_prefers_challenge_scope_then_metadata_scope() {
943 assert_eq!(
944 select_oauth_scopes(Some("files:read files:write files:read"), &[]),
945 vec!["files:read", "files:write"]
946 );
947 assert_eq!(
948 select_oauth_scopes(None, &["basic".to_string(), "profile".to_string()]),
949 vec!["basic", "profile"]
950 );
951 }
952
953 #[test]
954 fn client_registration_mode_selection_is_explicit() {
955 let mut meta = metadata(
956 "https://auth.example.com",
957 Some("https://auth.example.com/reg"),
958 );
959 assert_eq!(
960 select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
961 OAuthClientRegistrationMode::DynamicClientRegistration
962 );
963 assert_eq!(
964 select_client_registration_mode(
965 &meta,
966 OAuthClientRegistrationOptions {
967 client_id: Some("static-client"),
968 ..OAuthClientRegistrationOptions::default()
969 },
970 ),
971 OAuthClientRegistrationMode::PreRegistered
972 );
973 meta.client_id_metadata_document_supported = true;
974 assert_eq!(
975 select_client_registration_mode(
976 &meta,
977 OAuthClientRegistrationOptions {
978 client_id: Some("https://client.example/oauth/client.json"),
979 ..OAuthClientRegistrationOptions::default()
980 },
981 ),
982 OAuthClientRegistrationMode::ClientIdMetadataDocument
983 );
984 }
985
986 #[test]
987 fn dynamic_registration_body_marks_loopback_clients_native() {
988 let body = dynamic_client_registration_body(
989 "Harn CLI",
990 ["http://127.0.0.1:49152/oauth/callback"],
991 Some("mcp.read"),
992 );
993 assert_eq!(body["application_type"], "native");
994 assert_eq!(body["token_endpoint_auth_method"], "none");
995 assert_eq!(body["grant_types"][1], "refresh_token");
996 assert_eq!(body["scope"], "mcp.read");
997 }
998
999 #[test]
1000 fn token_refresh_binding_rejects_cross_issuer_reuse() {
1001 assert!(
1002 validate_issuer_binding("https://issuer-a.example", "https://issuer-a.example").is_ok()
1003 );
1004 assert!(
1005 validate_issuer_binding("https://issuer-a.example", "https://issuer-b.example")
1006 .is_err()
1007 );
1008 }
1009}