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";
20pub const DEFAULT_MCP_OAUTH_CLIENT_ID_METADATA_DOCUMENT_URL: &str =
21 "https://harnlang.com/.well-known/oauth-client.json";
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct WwwAuthenticateChallenge {
25 pub scheme: String,
26 pub params: BTreeMap<String, String>,
27}
28
29impl WwwAuthenticateChallenge {
30 pub fn bearer_resource_metadata(&self) -> Option<&str> {
31 self.scheme
32 .eq_ignore_ascii_case("bearer")
33 .then(|| self.params.get("resource_metadata").map(String::as_str))
34 .flatten()
35 }
36
37 pub fn bearer_scope(&self) -> Option<&str> {
38 self.scheme
39 .eq_ignore_ascii_case("bearer")
40 .then(|| self.params.get("scope").map(String::as_str))
41 .flatten()
42 }
43}
44
45#[derive(Clone, Debug, Default, Deserialize, Serialize)]
46pub struct OAuthProtectedResourceMetadata {
47 #[serde(default)]
48 pub resource: Option<String>,
49 #[serde(default)]
50 pub authorization_servers: Vec<String>,
51 #[serde(default)]
52 pub scopes_supported: Vec<String>,
53 #[serde(default)]
54 pub bearer_methods_supported: Vec<String>,
55 #[serde(flatten)]
56 pub extra: BTreeMap<String, JsonValue>,
57}
58
59#[derive(Clone, Debug, Deserialize, Serialize)]
60pub struct OAuthAuthorizationServerMetadata {
61 pub issuer: String,
62 pub authorization_endpoint: String,
63 pub token_endpoint: String,
64 #[serde(default)]
65 pub registration_endpoint: Option<String>,
66 #[serde(default)]
67 pub token_endpoint_auth_methods_supported: Vec<String>,
68 #[serde(default)]
69 pub code_challenge_methods_supported: Vec<String>,
70 #[serde(default)]
71 pub scopes_supported: Vec<String>,
72 #[serde(default)]
73 pub client_id_metadata_document_supported: bool,
74 #[serde(default)]
75 pub authorization_response_iss_parameter_supported: bool,
76 #[serde(flatten)]
77 pub extra: BTreeMap<String, JsonValue>,
78}
79
80#[derive(Clone, Debug, Default, Deserialize, Serialize)]
81pub struct OAuthDynamicClientRegistrationResponse {
82 pub client_id: String,
83 #[serde(default)]
84 pub client_secret: Option<String>,
85 #[serde(default)]
86 pub token_endpoint_auth_method: Option<String>,
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
90#[serde(rename_all = "snake_case")]
91pub enum OAuthAuthorizationServerMetadataKind {
92 OAuthAuthorizationServer,
93 OpenIdConfiguration,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct OAuthAuthorizationServerMetadataCandidate {
98 pub url: Url,
99 pub kind: OAuthAuthorizationServerMetadataKind,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum OAuthClientRegistrationMode {
105 PreRegistered,
106 ClientIdMetadataDocument,
107 DynamicClientRegistration,
108 Manual,
109}
110
111impl OAuthClientRegistrationMode {
112 pub fn as_str(self) -> &'static str {
113 match self {
114 Self::PreRegistered => "pre_registered",
115 Self::ClientIdMetadataDocument => "client_id_metadata_document",
116 Self::DynamicClientRegistration => "dynamic_client_registration",
117 Self::Manual => "manual",
118 }
119 }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum OAuthClientAuthMode {
125 Cimd,
126 Dcr,
127 Static,
128 Byo,
129}
130
131impl OAuthClientAuthMode {
132 pub fn as_str(self) -> &'static str {
133 match self {
134 Self::Cimd => "cimd",
135 Self::Dcr => "dcr",
136 Self::Static => "static",
137 Self::Byo => "byo",
138 }
139 }
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum OAuthApplicationType {
145 Native,
146 Web,
147}
148
149impl OAuthApplicationType {
150 pub fn as_str(self) -> &'static str {
151 match self {
152 Self::Native => "native",
153 Self::Web => "web",
154 }
155 }
156}
157
158#[derive(Clone, Debug, Default)]
159pub struct OAuthClientRegistrationOptions<'a> {
160 pub client_id: Option<&'a str>,
161 pub client_secret: Option<&'a str>,
162 pub client_id_metadata_document_url: Option<&'a str>,
163}
164
165#[derive(Clone, Debug, Default)]
166pub struct OAuthClientAuthOptions<'a> {
167 pub mode: Option<OAuthClientAuthMode>,
168 pub client_id: Option<&'a str>,
169 pub client_secret: Option<&'a str>,
170 pub client_id_metadata_document_url: Option<&'a str>,
171 pub static_secret_id: Option<&'a str>,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct OAuthClientAuthSelection<'a> {
176 pub mode: OAuthClientAuthMode,
177 pub client_id: Option<&'a str>,
178}
179
180#[derive(Clone, Debug)]
181pub struct McpOAuthDiscovery {
182 pub protected_resource_metadata_url: Url,
183 pub protected_resource_metadata: OAuthProtectedResourceMetadata,
184 pub authorization_server_issuer: String,
185 pub authorization_server_metadata_url: Url,
186 pub authorization_server_metadata_kind: OAuthAuthorizationServerMetadataKind,
187 pub authorization_server_metadata: OAuthAuthorizationServerMetadata,
188 pub challenge: Option<WwwAuthenticateChallenge>,
189 pub scopes: Vec<String>,
190}
191
192#[derive(Debug)]
193pub enum McpOAuthDiscoveryError {
194 InvalidResourceUrl(String),
195 InvalidResourceMetadataUrl(String),
196 InvalidAuthorizationServerUrl { issuer: String, error: String },
197 ProtectedResourceMetadataNotFound,
198 MissingAuthorizationServer,
199 AuthorizationServerMetadataNotFound { issuer: String },
200 AuthorizationServerIssuerMismatch { expected: String, actual: String },
201 AuthorizationServerIssuerMissing { expected: String },
202 Json { url: String, error: String },
203}
204
205impl fmt::Display for McpOAuthDiscoveryError {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 match self {
208 Self::InvalidResourceUrl(error) => write!(f, "invalid MCP resource URL: {error}"),
209 Self::InvalidResourceMetadataUrl(error) => {
210 write!(f, "invalid resource_metadata URL in WWW-Authenticate: {error}")
211 }
212 Self::InvalidAuthorizationServerUrl { issuer, error } => {
213 write!(f, "invalid authorization server URL '{issuer}': {error}")
214 }
215 Self::ProtectedResourceMetadataNotFound => {
216 write!(f, "OAuth protected resource metadata not found")
217 }
218 Self::MissingAuthorizationServer => write!(
219 f,
220 "OAuth protected resource metadata did not advertise an authorization server"
221 ),
222 Self::AuthorizationServerMetadataNotFound { issuer } => {
223 write!(f, "authorization server metadata not found for issuer '{issuer}'")
224 }
225 Self::AuthorizationServerIssuerMismatch { expected, actual } => write!(
226 f,
227 "authorization server metadata issuer mismatch: expected '{expected}', got '{actual}'"
228 ),
229 Self::AuthorizationServerIssuerMissing { expected } => write!(
230 f,
231 "authorization server metadata for '{expected}' did not include an issuer"
232 ),
233 Self::Json { url, error } => write!(f, "failed to parse {url}: {error}"),
234 }
235 }
236}
237
238impl std::error::Error for McpOAuthDiscoveryError {}
239
240pub fn parse_www_authenticate(header: &str) -> Vec<WwwAuthenticateChallenge> {
241 let mut challenges = Vec::<WwwAuthenticateChallenge>::new();
242 let mut current: Option<WwwAuthenticateChallenge> = None;
243
244 for segment in split_challenge_segments(header) {
245 let segment = segment.trim();
246 if segment.is_empty() {
247 continue;
248 }
249 let (first, rest) = split_first_token(segment);
250 let starts_challenge = !first.contains('=');
251 if starts_challenge {
252 if let Some(challenge) = current.take() {
253 challenges.push(challenge);
254 }
255 let mut challenge = WwwAuthenticateChallenge {
256 scheme: first.to_string(),
257 params: BTreeMap::new(),
258 };
259 if !rest.trim().is_empty() {
260 parse_auth_param(rest.trim(), &mut challenge.params);
261 }
262 current = Some(challenge);
263 } else if let Some(challenge) = current.as_mut() {
264 parse_auth_param(segment, &mut challenge.params);
265 }
266 }
267
268 if let Some(challenge) = current {
269 challenges.push(challenge);
270 }
271 challenges
272}
273
274pub fn parse_www_authenticate_headers<'a>(
275 headers: impl IntoIterator<Item = &'a str>,
276) -> Vec<WwwAuthenticateChallenge> {
277 headers
278 .into_iter()
279 .flat_map(parse_www_authenticate)
280 .collect()
281}
282
283pub fn bearer_challenge_from_headers<'a>(
284 headers: impl IntoIterator<Item = &'a str>,
285) -> Option<WwwAuthenticateChallenge> {
286 let mut first_bearer = None;
287 for challenge in parse_www_authenticate_headers(headers) {
288 if !challenge.scheme.eq_ignore_ascii_case("bearer") {
289 continue;
290 }
291 if challenge.bearer_resource_metadata().is_some() {
292 return Some(challenge);
293 }
294 first_bearer.get_or_insert(challenge);
295 }
296 first_bearer
297}
298
299pub fn canonical_resource_indicator(server_url: &str) -> Result<String, McpOAuthDiscoveryError> {
307 let mut url = Url::parse(server_url)
308 .map_err(|error| McpOAuthDiscoveryError::InvalidResourceUrl(error.to_string()))?;
309 url.set_fragment(None);
312 url.set_query(None);
313 let _ = url.set_scheme(&url.scheme().to_ascii_lowercase());
314 if let Some(host) = url.host_str() {
315 let lowered = host.to_ascii_lowercase();
316 if lowered != host {
317 let _ = url.set_host(Some(&lowered));
318 }
319 }
320 if let Some(port) = url.port() {
323 if Some(port) == default_port_for_scheme(url.scheme()) {
324 let _ = url.set_port(None);
325 }
326 }
327 let mut canonical = url.to_string();
328 canonical = canonical.trim_end_matches('/').to_string();
329 Ok(canonical)
330}
331
332#[derive(Clone, Copy, Debug)]
333pub struct OAuthAuthorizationUrlOptions<'a> {
334 pub authorization_endpoint: &'a str,
335 pub client_id: &'a str,
336 pub redirect_uri: &'a str,
337 pub state: &'a str,
338 pub code_challenge: &'a str,
339 pub resource: &'a str,
340 pub scopes: Option<&'a str>,
341}
342
343pub fn build_oauth_authorization_url(
344 options: OAuthAuthorizationUrlOptions<'_>,
345) -> Result<Url, String> {
346 let mut url = Url::parse(options.authorization_endpoint)
347 .map_err(|error| format!("Invalid authorization endpoint: {error}"))?;
348 {
349 let mut query = url.query_pairs_mut();
350 query.append_pair("response_type", "code");
351 query.append_pair("client_id", options.client_id);
352 query.append_pair("redirect_uri", options.redirect_uri);
353 query.append_pair("state", options.state);
354 query.append_pair("code_challenge", options.code_challenge);
355 query.append_pair("code_challenge_method", "S256");
356 query.append_pair("resource", options.resource);
357 if let Some(scopes) = options.scopes {
358 query.append_pair("scope", scopes);
359 }
360 }
361 Ok(url)
362}
363
364#[derive(Clone, Copy, Debug)]
365pub struct OAuthAuthorizationCodeTokenForm<'a> {
366 pub client_id: &'a str,
367 pub redirect_uri: &'a str,
368 pub code: &'a str,
369 pub code_verifier: &'a str,
370 pub resource: &'a str,
371 pub scopes: Option<&'a str>,
372}
373
374pub fn authorization_code_token_form(
375 request: OAuthAuthorizationCodeTokenForm<'_>,
376) -> Vec<(&'static str, String)> {
377 let mut form = vec![
378 ("grant_type", "authorization_code".to_string()),
379 ("code", request.code.to_string()),
380 ("redirect_uri", request.redirect_uri.to_string()),
381 ("client_id", request.client_id.to_string()),
382 ("code_verifier", request.code_verifier.to_string()),
383 ("resource", request.resource.to_string()),
384 ];
385 if let Some(scopes) = request.scopes {
386 form.push(("scope", scopes.to_string()));
387 }
388 form
389}
390
391#[derive(Clone, Copy, Debug)]
392pub struct OAuthRefreshTokenForm<'a> {
393 pub client_id: &'a str,
394 pub refresh_token: &'a str,
395 pub resource: &'a str,
396}
397
398pub fn refresh_token_form(request: OAuthRefreshTokenForm<'_>) -> Vec<(&'static str, String)> {
399 vec![
400 ("grant_type", "refresh_token".to_string()),
401 ("refresh_token", request.refresh_token.to_string()),
402 ("client_id", request.client_id.to_string()),
403 ("resource", request.resource.to_string()),
404 ]
405}
406
407pub fn protected_resource_metadata_candidates(resource_url: &Url) -> Vec<Url> {
408 let mut urls = Vec::new();
409 let path = resource_url
410 .path()
411 .trim_start_matches('/')
412 .trim_end_matches('/');
413 if !path.is_empty() {
414 let mut url = resource_url.clone();
415 url.set_path(&format!(
416 "{OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH}/{path}"
417 ));
418 url.set_query(None);
419 url.set_fragment(None);
420 urls.push(url);
421 }
422 let mut root = resource_url.clone();
423 root.set_path(OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH);
424 root.set_query(None);
425 root.set_fragment(None);
426 urls.push(root);
427 urls
428}
429
430pub fn protected_resource_metadata_path(mcp_path: &str) -> String {
431 let mcp_path = normalize_path(mcp_path);
432 if mcp_path == "/" {
433 OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH.to_string()
434 } else {
435 format!("{OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH}{mcp_path}")
436 }
437}
438
439pub fn authorization_server_metadata_candidates(
440 auth_server_url: &Url,
441) -> Vec<OAuthAuthorizationServerMetadataCandidate> {
442 let mut urls = Vec::new();
443 let path = auth_server_url.path();
444 let has_path = !path.is_empty() && path != "/";
445 if has_path {
446 let trimmed = path.trim_start_matches('/');
447
448 let mut oauth = auth_server_url.clone();
449 oauth.set_path(&format!(
450 "{OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH}/{trimmed}"
451 ));
452 oauth.set_query(None);
453 oauth.set_fragment(None);
454 urls.push(OAuthAuthorizationServerMetadataCandidate {
455 url: oauth,
456 kind: OAuthAuthorizationServerMetadataKind::OAuthAuthorizationServer,
457 });
458
459 let mut oidc_inserted = auth_server_url.clone();
460 oidc_inserted.set_path(&format!("{OIDC_CONFIGURATION_WELL_KNOWN_PATH}/{trimmed}"));
461 oidc_inserted.set_query(None);
462 oidc_inserted.set_fragment(None);
463 urls.push(OAuthAuthorizationServerMetadataCandidate {
464 url: oidc_inserted,
465 kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
466 });
467
468 let mut oidc_appended = auth_server_url.clone();
469 let base = path.trim_end_matches('/');
470 oidc_appended.set_path(&format!("{base}{OIDC_CONFIGURATION_WELL_KNOWN_PATH}"));
471 oidc_appended.set_query(None);
472 oidc_appended.set_fragment(None);
473 urls.push(OAuthAuthorizationServerMetadataCandidate {
474 url: oidc_appended,
475 kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
476 });
477 return urls;
478 }
479
480 let mut oauth = auth_server_url.clone();
481 oauth.set_path(OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH);
482 oauth.set_query(None);
483 oauth.set_fragment(None);
484 urls.push(OAuthAuthorizationServerMetadataCandidate {
485 url: oauth,
486 kind: OAuthAuthorizationServerMetadataKind::OAuthAuthorizationServer,
487 });
488
489 let mut oidc = auth_server_url.clone();
490 oidc.set_path(OIDC_CONFIGURATION_WELL_KNOWN_PATH);
491 oidc.set_query(None);
492 oidc.set_fragment(None);
493 urls.push(OAuthAuthorizationServerMetadataCandidate {
494 url: oidc,
495 kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
496 });
497 urls
498}
499
500pub async fn discover_mcp_oauth(
501 client: &reqwest::Client,
502 resource: &str,
503) -> Result<McpOAuthDiscovery, McpOAuthDiscoveryError> {
504 let resource_url = Url::parse(resource)
505 .map_err(|error| McpOAuthDiscoveryError::InvalidResourceUrl(error.to_string()))?;
506 discover_mcp_oauth_from_url(client, &resource_url).await
507}
508
509pub async fn discover_mcp_oauth_from_url(
510 client: &reqwest::Client,
511 resource_url: &Url,
512) -> Result<McpOAuthDiscovery, McpOAuthDiscoveryError> {
513 let challenge = fetch_resource_challenge(client, resource_url).await;
514 let challenged_metadata_url = challenge
515 .as_ref()
516 .and_then(WwwAuthenticateChallenge::bearer_resource_metadata)
517 .map(|url| {
518 Url::parse(url).map_err(|error| {
519 McpOAuthDiscoveryError::InvalidResourceMetadataUrl(error.to_string())
520 })
521 })
522 .transpose()?;
523
524 let metadata_candidates = challenged_metadata_url
525 .into_iter()
526 .chain(protected_resource_metadata_candidates(resource_url))
527 .collect::<Vec<_>>();
528 let (protected_resource_metadata_url, protected_resource_metadata) =
529 fetch_first_json::<OAuthProtectedResourceMetadata>(client, &metadata_candidates)
530 .await?
531 .ok_or(McpOAuthDiscoveryError::ProtectedResourceMetadataNotFound)?;
532 let authorization_server_issuer = protected_resource_metadata
533 .authorization_servers
534 .first()
535 .cloned()
536 .ok_or(McpOAuthDiscoveryError::MissingAuthorizationServer)?;
537 let auth_server_url = Url::parse(&authorization_server_issuer).map_err(|error| {
538 McpOAuthDiscoveryError::InvalidAuthorizationServerUrl {
539 issuer: authorization_server_issuer.clone(),
540 error: error.to_string(),
541 }
542 })?;
543 let (authorization_server_metadata_url, authorization_server_metadata_kind, metadata) =
544 fetch_authorization_server_metadata(client, &authorization_server_issuer, &auth_server_url)
545 .await?;
546 let scopes = select_oauth_scopes(
547 challenge
548 .as_ref()
549 .and_then(WwwAuthenticateChallenge::bearer_scope),
550 &protected_resource_metadata.scopes_supported,
551 );
552 Ok(McpOAuthDiscovery {
553 protected_resource_metadata_url,
554 protected_resource_metadata,
555 authorization_server_issuer,
556 authorization_server_metadata_url,
557 authorization_server_metadata_kind,
558 authorization_server_metadata: metadata,
559 challenge,
560 scopes,
561 })
562}
563
564pub async fn fetch_authorization_server_metadata(
565 client: &reqwest::Client,
566 expected_issuer: &str,
567 auth_server_url: &Url,
568) -> Result<
569 (
570 Url,
571 OAuthAuthorizationServerMetadataKind,
572 OAuthAuthorizationServerMetadata,
573 ),
574 McpOAuthDiscoveryError,
575> {
576 let candidates = authorization_server_metadata_candidates(auth_server_url);
577 for candidate in candidates {
578 let Some(metadata) =
579 fetch_json::<OAuthAuthorizationServerMetadata>(client, &candidate.url).await?
580 else {
581 continue;
582 };
583 validate_authorization_server_issuer(expected_issuer, &metadata)?;
584 return Ok((candidate.url, candidate.kind, metadata));
585 }
586 Err(
587 McpOAuthDiscoveryError::AuthorizationServerMetadataNotFound {
588 issuer: expected_issuer.to_string(),
589 },
590 )
591}
592
593pub fn validate_authorization_server_issuer(
594 expected_issuer: &str,
595 metadata: &OAuthAuthorizationServerMetadata,
596) -> Result<(), McpOAuthDiscoveryError> {
597 if metadata.issuer.is_empty() {
598 return Err(McpOAuthDiscoveryError::AuthorizationServerIssuerMissing {
599 expected: expected_issuer.to_string(),
600 });
601 }
602 if metadata.issuer != expected_issuer {
603 return Err(McpOAuthDiscoveryError::AuthorizationServerIssuerMismatch {
604 expected: expected_issuer.to_string(),
605 actual: metadata.issuer.clone(),
606 });
607 }
608 Ok(())
609}
610
611pub fn validate_authorization_response_issuer(
612 metadata: &OAuthAuthorizationServerMetadata,
613 response_issuer: Option<&str>,
614) -> Result<(), String> {
615 validate_authorization_response_issuer_value(
616 &metadata.issuer,
617 metadata.authorization_response_iss_parameter_supported,
618 response_issuer,
619 )
620}
621
622pub fn validate_authorization_response_issuer_value(
629 expected_issuer: &str,
630 iss_supported: bool,
631 response_issuer: Option<&str>,
632) -> Result<(), String> {
633 match (iss_supported, response_issuer) {
634 (_, Some(actual)) if actual == expected_issuer => Ok(()),
635 (_, Some(actual)) => Err(format!(
636 "authorization response issuer mismatch: expected '{expected_issuer}', got '{actual}'"
637 )),
638 (true, None) => Err(
639 "authorization response did not include required RFC 9207 iss parameter".to_string(),
640 ),
641 (false, None) => Ok(()),
642 }
643}
644
645pub fn validate_issuer_binding(stored_issuer: &str, current_issuer: &str) -> Result<(), String> {
646 if stored_issuer == current_issuer {
647 Ok(())
648 } else {
649 Err(format!(
650 "stored OAuth credentials are bound to issuer '{stored_issuer}', but the MCP resource now advertises '{current_issuer}'"
651 ))
652 }
653}
654
655pub fn select_oauth_scopes(
656 challenge_scope: Option<&str>,
657 scopes_supported: &[String],
658) -> Vec<String> {
659 let challenged = split_scope_value(challenge_scope);
660 if challenged.is_empty() {
661 dedupe_scopes(scopes_supported.iter().map(String::as_str))
662 } else {
663 challenged
664 }
665}
666
667pub fn accumulate_oauth_scopes<'a>(
668 existing: impl IntoIterator<Item = &'a str>,
669 challenged: impl IntoIterator<Item = &'a str>,
670) -> Vec<String> {
671 dedupe_scopes(existing.into_iter().chain(challenged))
672}
673
674pub fn split_scope_value(value: Option<&str>) -> Vec<String> {
675 dedupe_scopes(
676 value
677 .unwrap_or_default()
678 .split_whitespace()
679 .map(str::trim)
680 .filter(|scope| !scope.is_empty()),
681 )
682}
683
684pub fn select_client_registration_mode(
685 metadata: &OAuthAuthorizationServerMetadata,
686 options: OAuthClientRegistrationOptions<'_>,
687) -> OAuthClientRegistrationMode {
688 match select_oauth_client_auth(
689 metadata,
690 OAuthClientAuthOptions {
691 client_id: options.client_id,
692 client_secret: options.client_secret,
693 client_id_metadata_document_url: options.client_id_metadata_document_url,
694 ..OAuthClientAuthOptions::default()
695 },
696 ) {
697 Ok(selection) => match selection.mode {
698 OAuthClientAuthMode::Cimd => OAuthClientRegistrationMode::ClientIdMetadataDocument,
699 OAuthClientAuthMode::Dcr => OAuthClientRegistrationMode::DynamicClientRegistration,
700 OAuthClientAuthMode::Byo => OAuthClientRegistrationMode::PreRegistered,
701 OAuthClientAuthMode::Static => OAuthClientRegistrationMode::Manual,
702 },
703 Err(_) => OAuthClientRegistrationMode::Manual,
704 }
705}
706
707pub fn select_oauth_client_auth<'a>(
708 metadata: &OAuthAuthorizationServerMetadata,
709 options: OAuthClientAuthOptions<'a>,
710) -> Result<OAuthClientAuthSelection<'a>, String> {
711 if let Some(mode) = options.mode {
712 return match mode {
713 OAuthClientAuthMode::Static => {
714 if options.static_secret_id.is_none() {
715 return Err("static MCP auth requires a secret_id".to_string());
716 }
717 Ok(OAuthClientAuthSelection {
718 mode,
719 client_id: None,
720 })
721 }
722 OAuthClientAuthMode::Byo => {
723 let client_id = options
724 .client_id
725 .ok_or_else(|| "BYO OAuth auth requires client_id".to_string())?;
726 Ok(OAuthClientAuthSelection {
727 mode,
728 client_id: Some(client_id),
729 })
730 }
731 OAuthClientAuthMode::Cimd => {
732 if !metadata.client_id_metadata_document_supported {
733 return Err(
734 "authorization server does not advertise Client ID Metadata Document support"
735 .to_string(),
736 );
737 }
738 let client_id = cimd_client_id(options);
739 if !is_client_id_metadata_document_url(client_id) {
740 return Err(
741 "CIMD OAuth auth requires an HTTPS client metadata document URL"
742 .to_string(),
743 );
744 }
745 Ok(OAuthClientAuthSelection {
746 mode,
747 client_id: Some(client_id),
748 })
749 }
750 OAuthClientAuthMode::Dcr => {
751 if metadata.registration_endpoint.is_none() {
752 return Err(
753 "authorization server does not advertise dynamic client registration"
754 .to_string(),
755 );
756 }
757 Ok(OAuthClientAuthSelection {
758 mode,
759 client_id: None,
760 })
761 }
762 };
763 }
764
765 if options.static_secret_id.is_some() {
766 return Ok(OAuthClientAuthSelection {
767 mode: OAuthClientAuthMode::Static,
768 client_id: None,
769 });
770 }
771
772 if let Some(client_id) = options.client_id {
773 if options.client_secret.is_none()
774 && is_client_id_metadata_document_url(client_id)
775 && metadata.client_id_metadata_document_supported
776 {
777 return Ok(OAuthClientAuthSelection {
778 mode: OAuthClientAuthMode::Cimd,
779 client_id: Some(client_id),
780 });
781 }
782 return Ok(OAuthClientAuthSelection {
783 mode: OAuthClientAuthMode::Byo,
784 client_id: Some(client_id),
785 });
786 }
787
788 if metadata.client_id_metadata_document_supported {
789 return Ok(OAuthClientAuthSelection {
790 mode: OAuthClientAuthMode::Cimd,
791 client_id: Some(cimd_client_id(options)),
792 });
793 }
794 if metadata.registration_endpoint.is_some() {
795 return Ok(OAuthClientAuthSelection {
796 mode: OAuthClientAuthMode::Dcr,
797 client_id: None,
798 });
799 }
800 Err("No OAuth client authentication mode is available. Configure auth.mode = \"byo\" with a client_id, auth.mode = \"static\" with a secret_id, or use an authorization server that supports CIMD or dynamic client registration.".to_string())
801}
802
803fn cimd_client_id<'a>(options: OAuthClientAuthOptions<'a>) -> &'a str {
804 options
805 .client_id_metadata_document_url
806 .or(options.client_id)
807 .unwrap_or(DEFAULT_MCP_OAUTH_CLIENT_ID_METADATA_DOCUMENT_URL)
808}
809
810pub fn is_client_id_metadata_document_url(client_id: &str) -> bool {
811 Url::parse(client_id)
812 .ok()
813 .filter(|url| url.scheme() == "https")
814 .and_then(|url| {
815 let path = url.path().trim_matches('/');
816 (!path.is_empty()).then_some(())
817 })
818 .is_some()
819}
820
821pub fn ensure_pkce_s256_supported(
822 metadata: &OAuthAuthorizationServerMetadata,
823) -> Result<(), String> {
824 let methods = &metadata.code_challenge_methods_supported;
825 if methods.is_empty() || methods.iter().any(|method| method == "S256") {
826 return Ok(());
827 }
828 Err("Authorization server does not advertise PKCE S256 support".to_string())
829}
830
831pub fn determine_token_endpoint_auth_method(
832 metadata: &OAuthAuthorizationServerMetadata,
833 client_secret: Option<&str>,
834) -> Result<String, String> {
835 let methods = &metadata.token_endpoint_auth_methods_supported;
836 if client_secret.is_some() {
837 if methods.is_empty() || methods.iter().any(|method| method == "client_secret_post") {
838 return Ok("client_secret_post".to_string());
839 }
840 if methods.iter().any(|method| method == "client_secret_basic") {
841 return Ok("client_secret_basic".to_string());
842 }
843 return Err(
844 "Authorization server does not support client_secret_post or client_secret_basic"
845 .to_string(),
846 );
847 }
848
849 if methods.is_empty() || methods.iter().any(|method| method == "none") {
850 return Ok("none".to_string());
851 }
852 Err("Authorization server requires client authentication. Supply --client-secret or configure a registered client.".to_string())
853}
854
855pub fn validate_token_endpoint_auth_method(method: &str) -> Result<(), String> {
856 match method {
857 "none" | "client_secret_post" | "client_secret_basic" => Ok(()),
858 other => Err(format!(
859 "unsupported token auth method '{other}'; expected none, client_secret_post, or client_secret_basic"
860 )),
861 }
862}
863
864pub fn application_type_for_redirect_uris<'a>(
865 redirect_uris: impl IntoIterator<Item = &'a str>,
866) -> OAuthApplicationType {
867 if redirect_uris.into_iter().all(redirect_uri_is_native) {
868 OAuthApplicationType::Native
869 } else {
870 OAuthApplicationType::Web
871 }
872}
873
874pub fn dynamic_client_registration_body<'a>(
875 client_name: &str,
876 redirect_uris: impl IntoIterator<Item = &'a str>,
877 scopes: Option<&str>,
878) -> JsonValue {
879 let redirect_uris = redirect_uris
880 .into_iter()
881 .map(ToString::to_string)
882 .collect::<Vec<_>>();
883 let application_type =
884 application_type_for_redirect_uris(redirect_uris.iter().map(String::as_str));
885 let mut body = json!({
886 "client_name": client_name,
887 "redirect_uris": redirect_uris,
888 "grant_types": ["authorization_code", "refresh_token"],
889 "response_types": ["code"],
890 "token_endpoint_auth_method": "none",
891 "application_type": application_type.as_str(),
892 });
893 if let Some(scopes) = scopes.filter(|scopes| !scopes.trim().is_empty()) {
894 body["scope"] = json!(scopes);
895 }
896 body
897}
898
899pub fn bearer_challenge_value(
900 resource_metadata_url: &str,
901 scopes: &[String],
902 error: Option<BearerChallengeError<'_>>,
903) -> String {
904 let mut parts = vec![format!(
905 "resource_metadata=\"{}\"",
906 quote_auth_value(resource_metadata_url)
907 )];
908 if !scopes.is_empty() {
909 parts.push(format!("scope=\"{}\"", quote_auth_value(&scopes.join(" "))));
910 }
911 if let Some(error) = error {
912 parts.insert(0, format!("error=\"{}\"", quote_auth_value(error.code)));
913 if let Some(description) = error.description {
914 parts.push(format!(
915 "error_description=\"{}\"",
916 quote_auth_value(description)
917 ));
918 }
919 }
920 format!("Bearer {}", parts.join(", "))
921}
922
923#[derive(Clone, Copy, Debug, PartialEq, Eq)]
924pub struct BearerChallengeError<'a> {
925 pub code: &'a str,
926 pub description: Option<&'a str>,
927}
928
929fn split_challenge_segments(header: &str) -> Vec<&str> {
930 let mut segments = Vec::new();
931 let mut start = 0;
932 let mut in_quote = false;
933 let mut escaped = false;
934 for (index, character) in header.char_indices() {
935 if escaped {
936 escaped = false;
937 continue;
938 }
939 match character {
940 '\\' if in_quote => escaped = true,
941 '"' => in_quote = !in_quote,
942 ',' if !in_quote => {
943 segments.push(&header[start..index]);
944 start = index + 1;
945 }
946 _ => {}
947 }
948 }
949 segments.push(&header[start..]);
950 segments
951}
952
953fn split_first_token(segment: &str) -> (&str, &str) {
954 let trimmed = segment.trim_start();
955 match trimmed.find(char::is_whitespace) {
956 Some(index) => (&trimmed[..index], &trimmed[index..]),
957 None => (trimmed, ""),
958 }
959}
960
961fn parse_auth_param(segment: &str, params: &mut BTreeMap<String, String>) {
962 let Some((key, raw_value)) = segment.split_once('=') else {
963 return;
964 };
965 let key = key.trim().to_ascii_lowercase();
966 if key.is_empty() {
967 return;
968 }
969 params.insert(key, parse_auth_value(raw_value.trim()));
970}
971
972fn parse_auth_value(raw_value: &str) -> String {
973 let Some(stripped) = raw_value
974 .strip_prefix('"')
975 .and_then(|value| value.strip_suffix('"'))
976 else {
977 return raw_value.trim().to_string();
978 };
979 let mut value = String::new();
980 let mut chars = stripped.chars();
981 while let Some(character) = chars.next() {
982 if character == '\\' {
983 if let Some(escaped) = chars.next() {
984 value.push(escaped);
985 }
986 } else {
987 value.push(character);
988 }
989 }
990 value
991}
992
993async fn fetch_resource_challenge(
994 client: &reqwest::Client,
995 resource_url: &Url,
996) -> Option<WwwAuthenticateChallenge> {
997 let response = client
998 .get(resource_url.clone())
999 .header(ACCEPT, "application/json")
1000 .send()
1001 .await
1002 .ok()?;
1003 let header_values = response
1004 .headers()
1005 .get_all(WWW_AUTHENTICATE)
1006 .iter()
1007 .filter_map(|value| value.to_str().ok())
1008 .collect::<Vec<_>>();
1009 bearer_challenge_from_headers(header_values)
1010}
1011
1012async fn fetch_first_json<T: for<'de> Deserialize<'de>>(
1013 client: &reqwest::Client,
1014 candidates: &[Url],
1015) -> Result<Option<(Url, T)>, McpOAuthDiscoveryError> {
1016 for candidate in candidates {
1017 if let Some(parsed) = fetch_json::<T>(client, candidate).await? {
1018 return Ok(Some((candidate.clone(), parsed)));
1019 }
1020 }
1021 Ok(None)
1022}
1023
1024async fn fetch_json<T: for<'de> Deserialize<'de>>(
1025 client: &reqwest::Client,
1026 url: &Url,
1027) -> Result<Option<T>, McpOAuthDiscoveryError> {
1028 let response = match client.get(url.clone()).send().await {
1029 Ok(response) => response,
1030 Err(_) => return Ok(None),
1031 };
1032 if !response.status().is_success() {
1033 return Ok(None);
1034 }
1035 response
1036 .json::<T>()
1037 .await
1038 .map(Some)
1039 .map_err(|error| McpOAuthDiscoveryError::Json {
1040 url: url.to_string(),
1041 error: error.to_string(),
1042 })
1043}
1044
1045fn dedupe_scopes<'a>(scopes: impl IntoIterator<Item = &'a str>) -> Vec<String> {
1046 let mut seen = BTreeSet::new();
1047 let mut ordered = Vec::new();
1048 for scope in scopes {
1049 let scope = scope.trim();
1050 if !scope.is_empty() && seen.insert(scope.to_string()) {
1051 ordered.push(scope.to_string());
1052 }
1053 }
1054 ordered
1055}
1056
1057fn redirect_uri_is_native(redirect_uri: &str) -> bool {
1058 let Ok(url) = Url::parse(redirect_uri) else {
1059 return false;
1060 };
1061 if url.scheme() != "http" && url.scheme() != "https" {
1062 return true;
1063 }
1064 matches!(
1065 url.host_str(),
1066 Some("127.0.0.1") | Some("localhost") | Some("::1") | Some("[::1]")
1067 )
1068}
1069
1070fn normalize_path(path: &str) -> String {
1071 let trimmed = path.trim();
1072 if trimmed.is_empty() || trimmed == "/" {
1073 "/".to_string()
1074 } else if trimmed.starts_with('/') {
1075 trimmed.to_string()
1076 } else {
1077 format!("/{trimmed}")
1078 }
1079}
1080
1081fn quote_auth_value(value: &str) -> String {
1082 value.replace('\\', "\\\\").replace('"', "\\\"")
1083}
1084
1085fn default_port_for_scheme(scheme: &str) -> Option<u16> {
1086 match scheme {
1087 "http" | "ws" => Some(80),
1088 "https" | "wss" => Some(443),
1089 _ => None,
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 fn metadata(
1098 issuer: &str,
1099 registration_endpoint: Option<&str>,
1100 ) -> OAuthAuthorizationServerMetadata {
1101 OAuthAuthorizationServerMetadata {
1102 issuer: issuer.to_string(),
1103 authorization_endpoint: format!("{issuer}/authorize"),
1104 token_endpoint: format!("{issuer}/token"),
1105 registration_endpoint: registration_endpoint.map(ToString::to_string),
1106 token_endpoint_auth_methods_supported: vec!["none".to_string()],
1107 code_challenge_methods_supported: vec!["S256".to_string()],
1108 scopes_supported: Vec::new(),
1109 client_id_metadata_document_supported: false,
1110 authorization_response_iss_parameter_supported: false,
1111 extra: BTreeMap::new(),
1112 }
1113 }
1114
1115 #[test]
1116 fn canonical_resource_indicator_strips_trailing_slash() {
1117 assert_eq!(
1118 canonical_resource_indicator("https://mcp.example.com/").unwrap(),
1119 "https://mcp.example.com"
1120 );
1121 assert_eq!(
1122 canonical_resource_indicator("https://mcp.example.com").unwrap(),
1123 "https://mcp.example.com"
1124 );
1125 assert_eq!(
1126 canonical_resource_indicator("https://mcp.example.com/mcp/").unwrap(),
1127 "https://mcp.example.com/mcp"
1128 );
1129 }
1130
1131 #[test]
1132 fn canonical_resource_indicator_strips_fragment_and_query() {
1133 assert_eq!(
1134 canonical_resource_indicator("https://mcp.example.com/mcp?token=secret#section")
1135 .unwrap(),
1136 "https://mcp.example.com/mcp"
1137 );
1138 }
1139
1140 #[test]
1141 fn canonical_resource_indicator_lowercases_scheme_and_host() {
1142 assert_eq!(
1143 canonical_resource_indicator("HTTPS://MCP.Example.COM/Path").unwrap(),
1144 "https://mcp.example.com/Path"
1145 );
1146 }
1147
1148 #[test]
1149 fn canonical_resource_indicator_drops_default_ports() {
1150 assert_eq!(
1151 canonical_resource_indicator("https://mcp.example.com:443/").unwrap(),
1152 "https://mcp.example.com"
1153 );
1154 assert_eq!(
1155 canonical_resource_indicator("http://mcp.example.com:80").unwrap(),
1156 "http://mcp.example.com"
1157 );
1158 assert_eq!(
1159 canonical_resource_indicator("https://mcp.example.com:8443/mcp").unwrap(),
1160 "https://mcp.example.com:8443/mcp"
1161 );
1162 }
1163
1164 #[test]
1165 fn canonical_resource_indicator_preserves_non_empty_path_segments() {
1166 assert_eq!(
1167 canonical_resource_indicator("https://example.com/mcp/notion/").unwrap(),
1168 "https://example.com/mcp/notion"
1169 );
1170 }
1171
1172 #[test]
1173 fn oauth_authorization_url_includes_resource_indicator() {
1174 let url = build_oauth_authorization_url(OAuthAuthorizationUrlOptions {
1175 authorization_endpoint: "https://auth.example.com/authorize",
1176 client_id: "client-123",
1177 redirect_uri: "http://127.0.0.1:9783/oauth/callback",
1178 state: "state-abc",
1179 code_challenge: "challenge-xyz",
1180 resource: "https://mcp.example.com/mcp",
1181 scopes: Some("mcp.read"),
1182 })
1183 .unwrap();
1184 let params = url.query_pairs().collect::<BTreeMap<_, _>>();
1185 assert_eq!(
1186 params.get("resource").map(|value| value.as_ref()),
1187 Some("https://mcp.example.com/mcp")
1188 );
1189 assert_eq!(
1190 params.get("scope").map(|value| value.as_ref()),
1191 Some("mcp.read")
1192 );
1193 }
1194
1195 #[test]
1196 fn token_forms_include_resource_indicator() {
1197 let code_form = authorization_code_token_form(OAuthAuthorizationCodeTokenForm {
1198 client_id: "client-123",
1199 redirect_uri: "http://127.0.0.1:9783/oauth/callback",
1200 code: "code-abc",
1201 code_verifier: "verifier-xyz",
1202 resource: "https://mcp.example.com/mcp",
1203 scopes: Some("mcp.read"),
1204 });
1205 assert!(code_form.contains(&("resource", "https://mcp.example.com/mcp".to_string())));
1206 assert!(code_form.contains(&("scope", "mcp.read".to_string())));
1207
1208 let refresh_form = refresh_token_form(OAuthRefreshTokenForm {
1209 client_id: "client-123",
1210 refresh_token: "refresh-abc",
1211 resource: "https://mcp.example.com/mcp",
1212 });
1213 assert!(refresh_form.contains(&("resource", "https://mcp.example.com/mcp".to_string())));
1214 }
1215
1216 #[test]
1217 fn canonical_resource_indicator_rejects_invalid_url() {
1218 assert!(canonical_resource_indicator("not a url").is_err());
1219 }
1220
1221 #[test]
1222 fn parses_bearer_challenge_resource_metadata_and_scope() {
1223 let challenges = parse_www_authenticate(
1224 r#"Bearer realm="mcp", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read files:write""#,
1225 );
1226 assert_eq!(challenges.len(), 1);
1227 let challenge = &challenges[0];
1228 assert_eq!(
1229 challenge.bearer_resource_metadata(),
1230 Some("https://mcp.example/.well-known/oauth-protected-resource")
1231 );
1232 assert_eq!(
1233 split_scope_value(challenge.bearer_scope()),
1234 vec!["files:read", "files:write"]
1235 );
1236 }
1237
1238 #[test]
1239 fn parses_multiple_www_authenticate_challenges() {
1240 let challenge = bearer_challenge_from_headers([
1241 r#"Basic realm="old""#,
1242 r#"Bearer error="insufficient_scope", scope="admin", resource_metadata="https://mcp.example/meta""#,
1243 ])
1244 .expect("bearer challenge");
1245 assert_eq!(
1246 challenge.params.get("error").map(String::as_str),
1247 Some("insufficient_scope")
1248 );
1249 assert_eq!(
1250 challenge.bearer_resource_metadata(),
1251 Some("https://mcp.example/meta")
1252 );
1253 }
1254
1255 #[test]
1256 fn bearer_challenge_selection_prefers_resource_metadata() {
1257 let challenge = bearer_challenge_from_headers([
1258 r#"Bearer realm="old", Bearer resource_metadata="https://mcp.example/meta""#,
1259 ])
1260 .expect("bearer challenge");
1261 assert_eq!(
1262 challenge.bearer_resource_metadata(),
1263 Some("https://mcp.example/meta")
1264 );
1265 }
1266
1267 #[test]
1268 fn authorization_server_candidates_include_oidc_path_appending() {
1269 let issuer = Url::parse("https://auth.example.com/tenant1").unwrap();
1270 let candidates = authorization_server_metadata_candidates(&issuer);
1271 let urls = candidates
1272 .iter()
1273 .map(|candidate| candidate.url.as_str())
1274 .collect::<Vec<_>>();
1275 assert_eq!(
1276 urls,
1277 vec![
1278 "https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
1279 "https://auth.example.com/.well-known/openid-configuration/tenant1",
1280 "https://auth.example.com/tenant1/.well-known/openid-configuration",
1281 ]
1282 );
1283 }
1284
1285 #[test]
1286 fn validates_authorization_server_issuer_without_normalization() {
1287 let mut metadata = metadata("https://auth.example.com", None);
1288 validate_authorization_server_issuer("https://auth.example.com", &metadata).unwrap();
1289 metadata.issuer = "https://auth.example.com/".to_string();
1290 let err = validate_authorization_server_issuer("https://auth.example.com", &metadata)
1291 .expect_err("issuer mismatch");
1292 assert!(err.to_string().contains("issuer mismatch"));
1293 }
1294
1295 #[test]
1296 fn authorization_response_issuer_validation_follows_rfc9207_advertisement() {
1297 let mut metadata = metadata("https://auth.example.com", None);
1298 assert!(validate_authorization_response_issuer(&metadata, None).is_ok());
1299 assert!(
1300 validate_authorization_response_issuer(&metadata, Some("https://other.example"))
1301 .is_err()
1302 );
1303 metadata.authorization_response_iss_parameter_supported = true;
1304 assert!(validate_authorization_response_issuer(&metadata, None).is_err());
1305 assert!(validate_authorization_response_issuer(
1306 &metadata,
1307 Some("https://auth.example.com")
1308 )
1309 .is_ok());
1310 }
1311
1312 #[test]
1313 fn scope_selection_prefers_challenge_scope_then_metadata_scope() {
1314 assert_eq!(
1315 select_oauth_scopes(Some("files:read files:write files:read"), &[]),
1316 vec!["files:read", "files:write"]
1317 );
1318 assert_eq!(
1319 select_oauth_scopes(None, &["basic".to_string(), "profile".to_string()]),
1320 vec!["basic", "profile"]
1321 );
1322 }
1323
1324 #[test]
1325 fn client_registration_mode_selection_is_explicit() {
1326 let mut meta = metadata(
1327 "https://auth.example.com",
1328 Some("https://auth.example.com/reg"),
1329 );
1330 assert_eq!(
1331 select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
1332 OAuthClientRegistrationMode::DynamicClientRegistration
1333 );
1334 assert_eq!(
1335 select_client_registration_mode(
1336 &meta,
1337 OAuthClientRegistrationOptions {
1338 client_id: Some("static-client"),
1339 ..OAuthClientRegistrationOptions::default()
1340 },
1341 ),
1342 OAuthClientRegistrationMode::PreRegistered
1343 );
1344 meta.client_id_metadata_document_supported = true;
1345 assert_eq!(
1346 select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
1347 OAuthClientRegistrationMode::ClientIdMetadataDocument
1348 );
1349 assert_eq!(
1350 select_client_registration_mode(
1351 &meta,
1352 OAuthClientRegistrationOptions {
1353 client_id: Some("https://client.example/oauth/client.json"),
1354 ..OAuthClientRegistrationOptions::default()
1355 },
1356 ),
1357 OAuthClientRegistrationMode::ClientIdMetadataDocument
1358 );
1359 }
1360
1361 #[test]
1362 fn oauth_client_auth_prefers_cimd_before_dcr() {
1363 let mut meta = metadata(
1364 "https://auth.example.com",
1365 Some("https://auth.example.com/reg"),
1366 );
1367 meta.client_id_metadata_document_supported = true;
1368 let selection = select_oauth_client_auth(&meta, OAuthClientAuthOptions::default()).unwrap();
1369 assert_eq!(selection.mode, OAuthClientAuthMode::Cimd);
1370 assert_eq!(
1371 selection.client_id,
1372 Some(DEFAULT_MCP_OAUTH_CLIENT_ID_METADATA_DOCUMENT_URL)
1373 );
1374 }
1375
1376 #[test]
1377 fn oauth_client_auth_falls_back_to_dcr_without_cimd() {
1378 let meta = metadata(
1379 "https://auth.example.com",
1380 Some("https://auth.example.com/reg"),
1381 );
1382 let selection = select_oauth_client_auth(&meta, OAuthClientAuthOptions::default()).unwrap();
1383 assert_eq!(selection.mode, OAuthClientAuthMode::Dcr);
1384 assert_eq!(selection.client_id, None);
1385 }
1386
1387 #[test]
1388 fn oauth_client_auth_accepts_byo_secret_references_as_byo() {
1389 let meta = metadata("https://auth.example.com", None);
1390 let selection = select_oauth_client_auth(
1391 &meta,
1392 OAuthClientAuthOptions {
1393 mode: Some(OAuthClientAuthMode::Byo),
1394 client_id: Some("registered-client"),
1395 client_secret: Some("secret-from-store"),
1396 ..OAuthClientAuthOptions::default()
1397 },
1398 )
1399 .unwrap();
1400 assert_eq!(selection.mode, OAuthClientAuthMode::Byo);
1401 assert_eq!(selection.client_id, Some("registered-client"));
1402 }
1403
1404 #[test]
1405 fn explicit_cimd_auth_requires_metadata_document_url() {
1406 let mut meta = metadata("https://auth.example.com", None);
1407 meta.client_id_metadata_document_supported = true;
1408 let error = select_oauth_client_auth(
1409 &meta,
1410 OAuthClientAuthOptions {
1411 mode: Some(OAuthClientAuthMode::Cimd),
1412 client_id: Some("registered-client"),
1413 ..OAuthClientAuthOptions::default()
1414 },
1415 )
1416 .expect_err("invalid CIMD client id");
1417 assert!(error.contains("metadata document URL"));
1418 }
1419
1420 #[test]
1421 fn dynamic_registration_body_marks_loopback_clients_native() {
1422 let body = dynamic_client_registration_body(
1423 "Harn CLI",
1424 ["http://127.0.0.1:49152/oauth/callback"],
1425 Some("mcp.read"),
1426 );
1427 assert_eq!(body["application_type"], "native");
1428 assert_eq!(body["token_endpoint_auth_method"], "none");
1429 assert_eq!(body["grant_types"][1], "refresh_token");
1430 assert_eq!(body["scope"], "mcp.read");
1431 }
1432
1433 #[test]
1434 fn token_refresh_binding_rejects_cross_issuer_reuse() {
1435 assert!(
1436 validate_issuer_binding("https://issuer-a.example", "https://issuer-a.example").is_ok()
1437 );
1438 assert!(
1439 validate_issuer_binding("https://issuer-a.example", "https://issuer-b.example")
1440 .is_err()
1441 );
1442 }
1443}