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
949fn split_challenge_segments(header: &str) -> Vec<&str> {
950 let mut segments = Vec::new();
951 let mut start = 0;
952 let mut in_quote = false;
953 let mut escaped = false;
954 for (index, character) in header.char_indices() {
955 if escaped {
956 escaped = false;
957 continue;
958 }
959 match character {
960 '\\' if in_quote => escaped = true,
961 '"' => in_quote = !in_quote,
962 ',' if !in_quote => {
963 segments.push(&header[start..index]);
964 start = index + 1;
965 }
966 _ => {}
967 }
968 }
969 segments.push(&header[start..]);
970 segments
971}
972
973fn split_first_token(segment: &str) -> (&str, &str) {
974 let trimmed = segment.trim_start();
975 match trimmed.find(char::is_whitespace) {
976 Some(index) => (&trimmed[..index], &trimmed[index..]),
977 None => (trimmed, ""),
978 }
979}
980
981fn parse_auth_param(segment: &str, params: &mut BTreeMap<String, String>) {
982 let Some((key, raw_value)) = segment.split_once('=') else {
983 return;
984 };
985 let key = key.trim().to_ascii_lowercase();
986 if key.is_empty() {
987 return;
988 }
989 params.insert(key, parse_auth_value(raw_value.trim()));
990}
991
992fn parse_auth_value(raw_value: &str) -> String {
993 let Some(stripped) = raw_value
994 .strip_prefix('"')
995 .and_then(|value| value.strip_suffix('"'))
996 else {
997 return raw_value.trim().to_string();
998 };
999 let mut value = String::new();
1000 let mut chars = stripped.chars();
1001 while let Some(character) = chars.next() {
1002 if character == '\\' {
1003 if let Some(escaped) = chars.next() {
1004 value.push(escaped);
1005 }
1006 } else {
1007 value.push(character);
1008 }
1009 }
1010 value
1011}
1012
1013async fn fetch_resource_challenge(
1014 client: &reqwest::Client,
1015 resource_url: &Url,
1016) -> Option<WwwAuthenticateChallenge> {
1017 let response = client
1018 .get(resource_url.clone())
1019 .header(ACCEPT, "application/json")
1020 .send()
1021 .await
1022 .ok()?;
1023 let header_values = response
1024 .headers()
1025 .get_all(WWW_AUTHENTICATE)
1026 .iter()
1027 .filter_map(|value| value.to_str().ok())
1028 .collect::<Vec<_>>();
1029 bearer_challenge_from_headers(header_values)
1030}
1031
1032async fn fetch_first_json<T: for<'de> Deserialize<'de>>(
1033 client: &reqwest::Client,
1034 candidates: &[Url],
1035) -> Result<Option<(Url, T)>, McpOAuthDiscoveryError> {
1036 for candidate in candidates {
1037 if let Some(parsed) = fetch_json::<T>(client, candidate).await? {
1038 return Ok(Some((candidate.clone(), parsed)));
1039 }
1040 }
1041 Ok(None)
1042}
1043
1044async fn fetch_json<T: for<'de> Deserialize<'de>>(
1045 client: &reqwest::Client,
1046 url: &Url,
1047) -> Result<Option<T>, McpOAuthDiscoveryError> {
1048 let response = match client.get(url.clone()).send().await {
1049 Ok(response) => response,
1050 Err(_) => return Ok(None),
1051 };
1052 if !response.status().is_success() {
1053 return Ok(None);
1054 }
1055 response
1056 .json::<T>()
1057 .await
1058 .map(Some)
1059 .map_err(|error| McpOAuthDiscoveryError::Json {
1060 url: url.to_string(),
1061 error: error.to_string(),
1062 })
1063}
1064
1065fn dedupe_scopes<'a>(scopes: impl IntoIterator<Item = &'a str>) -> Vec<String> {
1066 let mut seen = BTreeSet::new();
1067 let mut ordered = Vec::new();
1068 for scope in scopes {
1069 let scope = scope.trim();
1070 if !scope.is_empty() && seen.insert(scope.to_string()) {
1071 ordered.push(scope.to_string());
1072 }
1073 }
1074 ordered
1075}
1076
1077fn redirect_uri_is_native(redirect_uri: &str) -> bool {
1078 let Ok(url) = Url::parse(redirect_uri) else {
1079 return false;
1080 };
1081 if url.scheme() != "http" && url.scheme() != "https" {
1082 return true;
1083 }
1084 matches!(
1085 url.host_str(),
1086 Some("127.0.0.1") | Some("localhost") | Some("::1") | Some("[::1]")
1087 )
1088}
1089
1090fn normalize_path(path: &str) -> String {
1091 let trimmed = path.trim();
1092 if trimmed.is_empty() || trimmed == "/" {
1093 "/".to_string()
1094 } else if trimmed.starts_with('/') {
1095 trimmed.to_string()
1096 } else {
1097 format!("/{trimmed}")
1098 }
1099}
1100
1101fn quote_auth_value(value: &str) -> String {
1102 value.replace('\\', "\\\\").replace('"', "\\\"")
1103}
1104
1105fn default_port_for_scheme(scheme: &str) -> Option<u16> {
1106 match scheme {
1107 "http" | "ws" => Some(80),
1108 "https" | "wss" => Some(443),
1109 _ => None,
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116
1117 fn metadata(
1118 issuer: &str,
1119 registration_endpoint: Option<&str>,
1120 ) -> OAuthAuthorizationServerMetadata {
1121 OAuthAuthorizationServerMetadata {
1122 issuer: issuer.to_string(),
1123 authorization_endpoint: format!("{issuer}/authorize"),
1124 token_endpoint: format!("{issuer}/token"),
1125 registration_endpoint: registration_endpoint.map(ToString::to_string),
1126 token_endpoint_auth_methods_supported: vec!["none".to_string()],
1127 code_challenge_methods_supported: vec!["S256".to_string()],
1128 scopes_supported: Vec::new(),
1129 client_id_metadata_document_supported: false,
1130 authorization_response_iss_parameter_supported: false,
1131 extra: BTreeMap::new(),
1132 }
1133 }
1134
1135 #[test]
1136 fn canonical_resource_indicator_strips_trailing_slash() {
1137 assert_eq!(
1138 canonical_resource_indicator("https://mcp.example.com/").unwrap(),
1139 "https://mcp.example.com"
1140 );
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/mcp/").unwrap(),
1147 "https://mcp.example.com/mcp"
1148 );
1149 }
1150
1151 #[test]
1152 fn bearer_error_and_insufficient_scope_detection() {
1153 let challenges =
1154 parse_www_authenticate(r#"Bearer error="insufficient_scope", scope="repo admin""#);
1155 let challenge = challenges.first().expect("one Bearer challenge");
1156 assert_eq!(challenge.bearer_error(), Some("insufficient_scope"));
1157 assert_eq!(challenge.bearer_scope(), Some("repo admin"));
1158 assert!(challenge.is_insufficient_scope());
1159
1160 let plain = parse_www_authenticate(r#"Bearer scope="repo""#);
1162 let plain = plain.first().expect("one Bearer challenge");
1163 assert_eq!(plain.bearer_error(), None);
1164 assert!(!plain.is_insufficient_scope());
1165
1166 let basic = parse_www_authenticate(r#"Basic realm="x", error="insufficient_scope""#);
1168 let basic = basic.first().expect("one challenge");
1169 assert_eq!(basic.bearer_error(), None);
1170 assert!(!basic.is_insufficient_scope());
1171 }
1172
1173 #[test]
1174 fn canonical_resource_indicator_strips_fragment_and_query() {
1175 assert_eq!(
1176 canonical_resource_indicator("https://mcp.example.com/mcp?token=secret#section")
1177 .unwrap(),
1178 "https://mcp.example.com/mcp"
1179 );
1180 }
1181
1182 #[test]
1183 fn canonical_resource_indicator_lowercases_scheme_and_host() {
1184 assert_eq!(
1185 canonical_resource_indicator("HTTPS://MCP.Example.COM/Path").unwrap(),
1186 "https://mcp.example.com/Path"
1187 );
1188 }
1189
1190 #[test]
1191 fn canonical_resource_indicator_drops_default_ports() {
1192 assert_eq!(
1193 canonical_resource_indicator("https://mcp.example.com:443/").unwrap(),
1194 "https://mcp.example.com"
1195 );
1196 assert_eq!(
1197 canonical_resource_indicator("http://mcp.example.com:80").unwrap(),
1198 "http://mcp.example.com"
1199 );
1200 assert_eq!(
1201 canonical_resource_indicator("https://mcp.example.com:8443/mcp").unwrap(),
1202 "https://mcp.example.com:8443/mcp"
1203 );
1204 }
1205
1206 #[test]
1207 fn canonical_resource_indicator_preserves_non_empty_path_segments() {
1208 assert_eq!(
1209 canonical_resource_indicator("https://example.com/mcp/notion/").unwrap(),
1210 "https://example.com/mcp/notion"
1211 );
1212 }
1213
1214 #[test]
1215 fn oauth_authorization_url_includes_resource_indicator() {
1216 let url = build_oauth_authorization_url(OAuthAuthorizationUrlOptions {
1217 authorization_endpoint: "https://auth.example.com/authorize",
1218 client_id: "client-123",
1219 redirect_uri: "http://127.0.0.1:9783/oauth/callback",
1220 state: "state-abc",
1221 code_challenge: "challenge-xyz",
1222 resource: "https://mcp.example.com/mcp",
1223 scopes: Some("mcp.read"),
1224 })
1225 .unwrap();
1226 let params = url.query_pairs().collect::<BTreeMap<_, _>>();
1227 assert_eq!(
1228 params.get("resource").map(|value| value.as_ref()),
1229 Some("https://mcp.example.com/mcp")
1230 );
1231 assert_eq!(
1232 params.get("scope").map(|value| value.as_ref()),
1233 Some("mcp.read")
1234 );
1235 }
1236
1237 #[test]
1238 fn token_forms_include_resource_indicator() {
1239 let code_form = authorization_code_token_form(OAuthAuthorizationCodeTokenForm {
1240 client_id: "client-123",
1241 redirect_uri: "http://127.0.0.1:9783/oauth/callback",
1242 code: "code-abc",
1243 code_verifier: "verifier-xyz",
1244 resource: "https://mcp.example.com/mcp",
1245 scopes: Some("mcp.read"),
1246 });
1247 assert!(code_form.contains(&("resource", "https://mcp.example.com/mcp".to_string())));
1248 assert!(code_form.contains(&("scope", "mcp.read".to_string())));
1249
1250 let refresh_form = refresh_token_form(OAuthRefreshTokenForm {
1251 client_id: "client-123",
1252 refresh_token: "refresh-abc",
1253 resource: "https://mcp.example.com/mcp",
1254 });
1255 assert!(refresh_form.contains(&("resource", "https://mcp.example.com/mcp".to_string())));
1256 }
1257
1258 #[test]
1259 fn canonical_resource_indicator_rejects_invalid_url() {
1260 assert!(canonical_resource_indicator("not a url").is_err());
1261 }
1262
1263 #[test]
1264 fn parses_bearer_challenge_resource_metadata_and_scope() {
1265 let challenges = parse_www_authenticate(
1266 r#"Bearer realm="mcp", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read files:write""#,
1267 );
1268 assert_eq!(challenges.len(), 1);
1269 let challenge = &challenges[0];
1270 assert_eq!(
1271 challenge.bearer_resource_metadata(),
1272 Some("https://mcp.example/.well-known/oauth-protected-resource")
1273 );
1274 assert_eq!(
1275 split_scope_value(challenge.bearer_scope()),
1276 vec!["files:read", "files:write"]
1277 );
1278 }
1279
1280 #[test]
1281 fn parses_multiple_www_authenticate_challenges() {
1282 let challenge = bearer_challenge_from_headers([
1283 r#"Basic realm="old""#,
1284 r#"Bearer error="insufficient_scope", scope="admin", resource_metadata="https://mcp.example/meta""#,
1285 ])
1286 .expect("bearer challenge");
1287 assert_eq!(
1288 challenge.params.get("error").map(String::as_str),
1289 Some("insufficient_scope")
1290 );
1291 assert_eq!(
1292 challenge.bearer_resource_metadata(),
1293 Some("https://mcp.example/meta")
1294 );
1295 }
1296
1297 #[test]
1298 fn bearer_challenge_selection_prefers_resource_metadata() {
1299 let challenge = bearer_challenge_from_headers([
1300 r#"Bearer realm="old", Bearer resource_metadata="https://mcp.example/meta""#,
1301 ])
1302 .expect("bearer challenge");
1303 assert_eq!(
1304 challenge.bearer_resource_metadata(),
1305 Some("https://mcp.example/meta")
1306 );
1307 }
1308
1309 #[test]
1310 fn authorization_server_candidates_include_oidc_path_appending() {
1311 let issuer = Url::parse("https://auth.example.com/tenant1").unwrap();
1312 let candidates = authorization_server_metadata_candidates(&issuer);
1313 let urls = candidates
1314 .iter()
1315 .map(|candidate| candidate.url.as_str())
1316 .collect::<Vec<_>>();
1317 assert_eq!(
1318 urls,
1319 vec![
1320 "https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
1321 "https://auth.example.com/.well-known/openid-configuration/tenant1",
1322 "https://auth.example.com/tenant1/.well-known/openid-configuration",
1323 ]
1324 );
1325 }
1326
1327 #[test]
1328 fn validates_authorization_server_issuer_without_normalization() {
1329 let mut metadata = metadata("https://auth.example.com", None);
1330 validate_authorization_server_issuer("https://auth.example.com", &metadata).unwrap();
1331 metadata.issuer = "https://auth.example.com/".to_string();
1332 let err = validate_authorization_server_issuer("https://auth.example.com", &metadata)
1333 .expect_err("issuer mismatch");
1334 assert!(err.to_string().contains("issuer mismatch"));
1335 }
1336
1337 #[test]
1338 fn authorization_response_issuer_validation_follows_rfc9207_advertisement() {
1339 let mut metadata = metadata("https://auth.example.com", None);
1340 assert!(validate_authorization_response_issuer(&metadata, None).is_ok());
1341 assert!(
1342 validate_authorization_response_issuer(&metadata, Some("https://other.example"))
1343 .is_err()
1344 );
1345 metadata.authorization_response_iss_parameter_supported = true;
1346 assert!(validate_authorization_response_issuer(&metadata, None).is_err());
1347 assert!(validate_authorization_response_issuer(
1348 &metadata,
1349 Some("https://auth.example.com")
1350 )
1351 .is_ok());
1352 }
1353
1354 #[test]
1355 fn scope_selection_prefers_challenge_scope_then_metadata_scope() {
1356 assert_eq!(
1357 select_oauth_scopes(Some("files:read files:write files:read"), &[]),
1358 vec!["files:read", "files:write"]
1359 );
1360 assert_eq!(
1361 select_oauth_scopes(None, &["basic".to_string(), "profile".to_string()]),
1362 vec!["basic", "profile"]
1363 );
1364 }
1365
1366 #[test]
1367 fn client_registration_mode_selection_is_explicit() {
1368 let mut meta = metadata(
1369 "https://auth.example.com",
1370 Some("https://auth.example.com/reg"),
1371 );
1372 assert_eq!(
1373 select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
1374 OAuthClientRegistrationMode::DynamicClientRegistration
1375 );
1376 assert_eq!(
1377 select_client_registration_mode(
1378 &meta,
1379 OAuthClientRegistrationOptions {
1380 client_id: Some("static-client"),
1381 ..OAuthClientRegistrationOptions::default()
1382 },
1383 ),
1384 OAuthClientRegistrationMode::PreRegistered
1385 );
1386 meta.client_id_metadata_document_supported = true;
1387 assert_eq!(
1388 select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
1389 OAuthClientRegistrationMode::ClientIdMetadataDocument
1390 );
1391 assert_eq!(
1392 select_client_registration_mode(
1393 &meta,
1394 OAuthClientRegistrationOptions {
1395 client_id: Some("https://client.example/oauth/client.json"),
1396 ..OAuthClientRegistrationOptions::default()
1397 },
1398 ),
1399 OAuthClientRegistrationMode::ClientIdMetadataDocument
1400 );
1401 }
1402
1403 #[test]
1404 fn oauth_client_auth_prefers_cimd_before_dcr() {
1405 let mut meta = metadata(
1406 "https://auth.example.com",
1407 Some("https://auth.example.com/reg"),
1408 );
1409 meta.client_id_metadata_document_supported = true;
1410 let selection = select_oauth_client_auth(&meta, OAuthClientAuthOptions::default()).unwrap();
1411 assert_eq!(selection.mode, OAuthClientAuthMode::Cimd);
1412 assert_eq!(
1413 selection.client_id,
1414 Some(DEFAULT_MCP_OAUTH_CLIENT_ID_METADATA_DOCUMENT_URL)
1415 );
1416 }
1417
1418 #[test]
1419 fn oauth_client_auth_falls_back_to_dcr_without_cimd() {
1420 let meta = metadata(
1421 "https://auth.example.com",
1422 Some("https://auth.example.com/reg"),
1423 );
1424 let selection = select_oauth_client_auth(&meta, OAuthClientAuthOptions::default()).unwrap();
1425 assert_eq!(selection.mode, OAuthClientAuthMode::Dcr);
1426 assert_eq!(selection.client_id, None);
1427 }
1428
1429 #[test]
1430 fn oauth_client_auth_accepts_byo_secret_references_as_byo() {
1431 let meta = metadata("https://auth.example.com", None);
1432 let selection = select_oauth_client_auth(
1433 &meta,
1434 OAuthClientAuthOptions {
1435 mode: Some(OAuthClientAuthMode::Byo),
1436 client_id: Some("registered-client"),
1437 client_secret: Some("secret-from-store"),
1438 ..OAuthClientAuthOptions::default()
1439 },
1440 )
1441 .unwrap();
1442 assert_eq!(selection.mode, OAuthClientAuthMode::Byo);
1443 assert_eq!(selection.client_id, Some("registered-client"));
1444 }
1445
1446 #[test]
1447 fn explicit_cimd_auth_requires_metadata_document_url() {
1448 let mut meta = metadata("https://auth.example.com", None);
1449 meta.client_id_metadata_document_supported = true;
1450 let error = select_oauth_client_auth(
1451 &meta,
1452 OAuthClientAuthOptions {
1453 mode: Some(OAuthClientAuthMode::Cimd),
1454 client_id: Some("registered-client"),
1455 ..OAuthClientAuthOptions::default()
1456 },
1457 )
1458 .expect_err("invalid CIMD client id");
1459 assert!(error.contains("metadata document URL"));
1460 }
1461
1462 #[test]
1463 fn dynamic_registration_body_marks_loopback_clients_native() {
1464 let body = dynamic_client_registration_body(
1465 "Harn CLI",
1466 ["http://127.0.0.1:49152/oauth/callback"],
1467 Some("mcp.read"),
1468 );
1469 assert_eq!(body["application_type"], "native");
1470 assert_eq!(body["token_endpoint_auth_method"], "none");
1471 assert_eq!(body["grant_types"][1], "refresh_token");
1472 assert_eq!(body["scope"], "mcp.read");
1473 }
1474
1475 #[test]
1476 fn token_refresh_binding_rejects_cross_issuer_reuse() {
1477 assert!(
1478 validate_issuer_binding("https://issuer-a.example", "https://issuer-a.example").is_ok()
1479 );
1480 assert!(
1481 validate_issuer_binding("https://issuer-a.example", "https://issuer-b.example")
1482 .is_err()
1483 );
1484 }
1485}