Skip to main content

harn_vm/
mcp_auth.rs

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