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