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    /// The RFC 6750 §3 `error` code carried by a Bearer challenge, e.g.
45    /// `invalid_token` or `insufficient_scope`. A resource server returns a
46    /// `403` with `error="insufficient_scope"` when the presented token is
47    /// valid but lacks a required scope — the cue to run a step-up
48    /// authorization requesting the additional scope from [`bearer_scope`].
49    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    /// True when this Bearer challenge signals `insufficient_scope` — a valid
57    /// token missing a required scope, resolvable by re-authorizing with the
58    /// elevated scope.
59    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
319/// Canonicalize an MCP server URL into the RFC 8707 resource indicator that
320/// MUST be sent as `resource` in both the authorization request and the token
321/// request.
322///
323/// The MCP authorization profile reuses RFC 8707 §2 resource-indicator
324/// canonicalization: the scheme and host are lowercased, a default port for the
325/// scheme is dropped, and the fragment, query, and trailing slash are removed.
326pub 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    // The `url` crate already lowercases the scheme and host on parse, but be
330    // explicit so the canonical form is stable regardless of input casing.
331    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    // Drop a default port so `https://host:443` and `https://host` canonicalize
341    // identically, while leaving non-default ports intact.
342    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
642/// Validate the RFC 9207 `iss` authorization-response parameter against the
643/// expected issuer. A redirect's `iss` (when present) must match; when the
644/// authorization server advertises `iss` support it MUST also be present.
645/// Callers that hold the parsed metadata should prefer
646/// [`validate_authorization_response_issuer`]; this value form is for paths
647/// that only retain the issuer + support flag (e.g. a pending OAuth flow).
648pub fn validate_authorization_response_issuer_value(
649    expected_issuer: &str,
650    iss_supported: bool,
651    response_issuer: Option<&str>,
652) -> Result<(), String> {
653    match (iss_supported, response_issuer) {
654        (_, Some(actual)) if actual == expected_issuer => Ok(()),
655        (_, Some(actual)) => Err(format!(
656            "authorization response issuer mismatch: expected '{expected_issuer}', got '{actual}'"
657        )),
658        (true, None) => Err(
659            "authorization response did not include required RFC 9207 iss parameter".to_string(),
660        ),
661        (false, None) => Ok(()),
662    }
663}
664
665pub fn validate_issuer_binding(stored_issuer: &str, current_issuer: &str) -> Result<(), String> {
666    if stored_issuer == current_issuer {
667        Ok(())
668    } else {
669        Err(format!(
670            "stored OAuth credentials are bound to issuer '{stored_issuer}', but the MCP resource now advertises '{current_issuer}'"
671        ))
672    }
673}
674
675pub fn select_oauth_scopes(
676    challenge_scope: Option<&str>,
677    scopes_supported: &[String],
678) -> Vec<String> {
679    let challenged = split_scope_value(challenge_scope);
680    if challenged.is_empty() {
681        dedupe_scopes(scopes_supported.iter().map(String::as_str))
682    } else {
683        challenged
684    }
685}
686
687pub fn accumulate_oauth_scopes<'a>(
688    existing: impl IntoIterator<Item = &'a str>,
689    challenged: impl IntoIterator<Item = &'a str>,
690) -> Vec<String> {
691    dedupe_scopes(existing.into_iter().chain(challenged))
692}
693
694pub fn split_scope_value(value: Option<&str>) -> Vec<String> {
695    dedupe_scopes(
696        value
697            .unwrap_or_default()
698            .split_whitespace()
699            .map(str::trim)
700            .filter(|scope| !scope.is_empty()),
701    )
702}
703
704pub fn select_client_registration_mode(
705    metadata: &OAuthAuthorizationServerMetadata,
706    options: OAuthClientRegistrationOptions<'_>,
707) -> OAuthClientRegistrationMode {
708    match select_oauth_client_auth(
709        metadata,
710        OAuthClientAuthOptions {
711            client_id: options.client_id,
712            client_secret: options.client_secret,
713            client_id_metadata_document_url: options.client_id_metadata_document_url,
714            ..OAuthClientAuthOptions::default()
715        },
716    ) {
717        Ok(selection) => match selection.mode {
718            OAuthClientAuthMode::Cimd => OAuthClientRegistrationMode::ClientIdMetadataDocument,
719            OAuthClientAuthMode::Dcr => OAuthClientRegistrationMode::DynamicClientRegistration,
720            OAuthClientAuthMode::Byo => OAuthClientRegistrationMode::PreRegistered,
721            OAuthClientAuthMode::Static => OAuthClientRegistrationMode::Manual,
722        },
723        Err(_) => OAuthClientRegistrationMode::Manual,
724    }
725}
726
727pub fn select_oauth_client_auth<'a>(
728    metadata: &OAuthAuthorizationServerMetadata,
729    options: OAuthClientAuthOptions<'a>,
730) -> Result<OAuthClientAuthSelection<'a>, String> {
731    if let Some(mode) = options.mode {
732        return match mode {
733            OAuthClientAuthMode::Static => {
734                if options.static_secret_id.is_none() {
735                    return Err("static MCP auth requires a secret_id".to_string());
736                }
737                Ok(OAuthClientAuthSelection {
738                    mode,
739                    client_id: None,
740                })
741            }
742            OAuthClientAuthMode::Byo => {
743                let client_id = options
744                    .client_id
745                    .ok_or_else(|| "BYO OAuth auth requires client_id".to_string())?;
746                Ok(OAuthClientAuthSelection {
747                    mode,
748                    client_id: Some(client_id),
749                })
750            }
751            OAuthClientAuthMode::Cimd => {
752                if !metadata.client_id_metadata_document_supported {
753                    return Err(
754                        "authorization server does not advertise Client ID Metadata Document support"
755                            .to_string(),
756                    );
757                }
758                let client_id = cimd_client_id(options);
759                if !is_client_id_metadata_document_url(client_id) {
760                    return Err(
761                        "CIMD OAuth auth requires an HTTPS client metadata document URL"
762                            .to_string(),
763                    );
764                }
765                Ok(OAuthClientAuthSelection {
766                    mode,
767                    client_id: Some(client_id),
768                })
769            }
770            OAuthClientAuthMode::Dcr => {
771                if metadata.registration_endpoint.is_none() {
772                    return Err(
773                        "authorization server does not advertise dynamic client registration"
774                            .to_string(),
775                    );
776                }
777                Ok(OAuthClientAuthSelection {
778                    mode,
779                    client_id: None,
780                })
781            }
782        };
783    }
784
785    if options.static_secret_id.is_some() {
786        return Ok(OAuthClientAuthSelection {
787            mode: OAuthClientAuthMode::Static,
788            client_id: None,
789        });
790    }
791
792    if let Some(client_id) = options.client_id {
793        if options.client_secret.is_none()
794            && is_client_id_metadata_document_url(client_id)
795            && metadata.client_id_metadata_document_supported
796        {
797            return Ok(OAuthClientAuthSelection {
798                mode: OAuthClientAuthMode::Cimd,
799                client_id: Some(client_id),
800            });
801        }
802        return Ok(OAuthClientAuthSelection {
803            mode: OAuthClientAuthMode::Byo,
804            client_id: Some(client_id),
805        });
806    }
807
808    if metadata.client_id_metadata_document_supported {
809        return Ok(OAuthClientAuthSelection {
810            mode: OAuthClientAuthMode::Cimd,
811            client_id: Some(cimd_client_id(options)),
812        });
813    }
814    if metadata.registration_endpoint.is_some() {
815        return Ok(OAuthClientAuthSelection {
816            mode: OAuthClientAuthMode::Dcr,
817            client_id: None,
818        });
819    }
820    Err("No OAuth client authentication mode is available. Configure auth.mode = \"byo\" with a client_id, auth.mode = \"static\" with a secret_id, or use an authorization server that supports CIMD or dynamic client registration.".to_string())
821}
822
823fn cimd_client_id<'a>(options: OAuthClientAuthOptions<'a>) -> &'a str {
824    options
825        .client_id_metadata_document_url
826        .or(options.client_id)
827        .unwrap_or(DEFAULT_MCP_OAUTH_CLIENT_ID_METADATA_DOCUMENT_URL)
828}
829
830pub fn is_client_id_metadata_document_url(client_id: &str) -> bool {
831    Url::parse(client_id)
832        .ok()
833        .filter(|url| url.scheme() == "https")
834        .and_then(|url| {
835            let path = url.path().trim_matches('/');
836            (!path.is_empty()).then_some(())
837        })
838        .is_some()
839}
840
841pub fn ensure_pkce_s256_supported(
842    metadata: &OAuthAuthorizationServerMetadata,
843) -> Result<(), String> {
844    let methods = &metadata.code_challenge_methods_supported;
845    if methods.is_empty() || methods.iter().any(|method| method == "S256") {
846        return Ok(());
847    }
848    Err("Authorization server does not advertise PKCE S256 support".to_string())
849}
850
851pub fn determine_token_endpoint_auth_method(
852    metadata: &OAuthAuthorizationServerMetadata,
853    client_secret: Option<&str>,
854) -> Result<String, String> {
855    let methods = &metadata.token_endpoint_auth_methods_supported;
856    if client_secret.is_some() {
857        if methods.is_empty() || methods.iter().any(|method| method == "client_secret_post") {
858            return Ok("client_secret_post".to_string());
859        }
860        if methods.iter().any(|method| method == "client_secret_basic") {
861            return Ok("client_secret_basic".to_string());
862        }
863        return Err(
864            "Authorization server does not support client_secret_post or client_secret_basic"
865                .to_string(),
866        );
867    }
868
869    if methods.is_empty() || methods.iter().any(|method| method == "none") {
870        return Ok("none".to_string());
871    }
872    Err("Authorization server requires client authentication. Supply --client-secret or configure a registered client.".to_string())
873}
874
875pub fn validate_token_endpoint_auth_method(method: &str) -> Result<(), String> {
876    match method {
877        "none" | "client_secret_post" | "client_secret_basic" => Ok(()),
878        other => Err(format!(
879            "unsupported token auth method '{other}'; expected none, client_secret_post, or client_secret_basic"
880        )),
881    }
882}
883
884pub fn application_type_for_redirect_uris<'a>(
885    redirect_uris: impl IntoIterator<Item = &'a str>,
886) -> OAuthApplicationType {
887    if redirect_uris.into_iter().all(redirect_uri_is_native) {
888        OAuthApplicationType::Native
889    } else {
890        OAuthApplicationType::Web
891    }
892}
893
894pub fn dynamic_client_registration_body<'a>(
895    client_name: &str,
896    redirect_uris: impl IntoIterator<Item = &'a str>,
897    scopes: Option<&str>,
898) -> JsonValue {
899    let redirect_uris = redirect_uris
900        .into_iter()
901        .map(ToString::to_string)
902        .collect::<Vec<_>>();
903    let application_type =
904        application_type_for_redirect_uris(redirect_uris.iter().map(String::as_str));
905    let mut body = json!({
906        "client_name": client_name,
907        "redirect_uris": redirect_uris,
908        "grant_types": ["authorization_code", "refresh_token"],
909        "response_types": ["code"],
910        "token_endpoint_auth_method": "none",
911        "application_type": application_type.as_str(),
912    });
913    if let Some(scopes) = scopes.filter(|scopes| !scopes.trim().is_empty()) {
914        body["scope"] = json!(scopes);
915    }
916    body
917}
918
919pub fn bearer_challenge_value(
920    resource_metadata_url: &str,
921    scopes: &[String],
922    error: Option<BearerChallengeError<'_>>,
923) -> String {
924    let mut parts = vec![format!(
925        "resource_metadata=\"{}\"",
926        quote_auth_value(resource_metadata_url)
927    )];
928    if !scopes.is_empty() {
929        parts.push(format!("scope=\"{}\"", quote_auth_value(&scopes.join(" "))));
930    }
931    if let Some(error) = error {
932        parts.insert(0, format!("error=\"{}\"", quote_auth_value(error.code)));
933        if let Some(description) = error.description {
934            parts.push(format!(
935                "error_description=\"{}\"",
936                quote_auth_value(description)
937            ));
938        }
939    }
940    format!("Bearer {}", parts.join(", "))
941}
942
943#[derive(Clone, Copy, Debug, PartialEq, Eq)]
944pub struct BearerChallengeError<'a> {
945    pub code: &'a str,
946    pub description: Option<&'a str>,
947}
948
949#[expect(
950    clippy::string_slice,
951    reason = "start/index are char_indices offsets of the ASCII ',' separator"
952)]
953fn split_challenge_segments(header: &str) -> Vec<&str> {
954    let mut segments = Vec::new();
955    let mut start = 0;
956    let mut in_quote = false;
957    let mut escaped = false;
958    for (index, character) in header.char_indices() {
959        if escaped {
960            escaped = false;
961            continue;
962        }
963        match character {
964            '\\' if in_quote => escaped = true,
965            '"' => in_quote = !in_quote,
966            ',' if !in_quote => {
967                segments.push(&header[start..index]);
968                start = index + 1;
969            }
970            _ => {}
971        }
972    }
973    segments.push(&header[start..]);
974    segments
975}
976
977fn split_first_token(segment: &str) -> (&str, &str) {
978    let trimmed = segment.trim_start();
979    match trimmed.find(char::is_whitespace) {
980        Some(index) => trimmed.split_at(index),
981        None => (trimmed, ""),
982    }
983}
984
985fn parse_auth_param(segment: &str, params: &mut BTreeMap<String, String>) {
986    let Some((key, raw_value)) = segment.split_once('=') else {
987        return;
988    };
989    let key = key.trim().to_ascii_lowercase();
990    if key.is_empty() {
991        return;
992    }
993    params.insert(key, parse_auth_value(raw_value.trim()));
994}
995
996fn parse_auth_value(raw_value: &str) -> String {
997    let Some(stripped) = raw_value
998        .strip_prefix('"')
999        .and_then(|value| value.strip_suffix('"'))
1000    else {
1001        return raw_value.trim().to_string();
1002    };
1003    let mut value = String::new();
1004    let mut chars = stripped.chars();
1005    while let Some(character) = chars.next() {
1006        if character == '\\' {
1007            if let Some(escaped) = chars.next() {
1008                value.push(escaped);
1009            }
1010        } else {
1011            value.push(character);
1012        }
1013    }
1014    value
1015}
1016
1017async fn fetch_resource_challenge(
1018    client: &reqwest::Client,
1019    resource_url: &Url,
1020) -> Option<WwwAuthenticateChallenge> {
1021    let response = client
1022        .get(resource_url.clone())
1023        .header(ACCEPT, "application/json")
1024        .send()
1025        .await
1026        .ok()?;
1027    let header_values = response
1028        .headers()
1029        .get_all(WWW_AUTHENTICATE)
1030        .iter()
1031        .filter_map(|value| value.to_str().ok())
1032        .collect::<Vec<_>>();
1033    bearer_challenge_from_headers(header_values)
1034}
1035
1036async fn fetch_first_json<T: for<'de> Deserialize<'de>>(
1037    client: &reqwest::Client,
1038    candidates: &[Url],
1039) -> Result<Option<(Url, T)>, McpOAuthDiscoveryError> {
1040    for candidate in candidates {
1041        if let Some(parsed) = fetch_json::<T>(client, candidate).await? {
1042            return Ok(Some((candidate.clone(), parsed)));
1043        }
1044    }
1045    Ok(None)
1046}
1047
1048async fn fetch_json<T: for<'de> Deserialize<'de>>(
1049    client: &reqwest::Client,
1050    url: &Url,
1051) -> Result<Option<T>, McpOAuthDiscoveryError> {
1052    let response = match client.get(url.clone()).send().await {
1053        Ok(response) => response,
1054        Err(_) => return Ok(None),
1055    };
1056    if !response.status().is_success() {
1057        return Ok(None);
1058    }
1059    response
1060        .json::<T>()
1061        .await
1062        .map(Some)
1063        .map_err(|error| McpOAuthDiscoveryError::Json {
1064            url: url.to_string(),
1065            error: error.to_string(),
1066        })
1067}
1068
1069fn dedupe_scopes<'a>(scopes: impl IntoIterator<Item = &'a str>) -> Vec<String> {
1070    let mut seen = BTreeSet::new();
1071    let mut ordered = Vec::new();
1072    for scope in scopes {
1073        let scope = scope.trim();
1074        if !scope.is_empty() && seen.insert(scope.to_string()) {
1075            ordered.push(scope.to_string());
1076        }
1077    }
1078    ordered
1079}
1080
1081fn redirect_uri_is_native(redirect_uri: &str) -> bool {
1082    let Ok(url) = Url::parse(redirect_uri) else {
1083        return false;
1084    };
1085    if url.scheme() != "http" && url.scheme() != "https" {
1086        return true;
1087    }
1088    matches!(
1089        url.host_str(),
1090        Some("127.0.0.1") | Some("localhost") | Some("::1") | Some("[::1]")
1091    )
1092}
1093
1094fn normalize_path(path: &str) -> String {
1095    let trimmed = path.trim();
1096    if trimmed.is_empty() || trimmed == "/" {
1097        "/".to_string()
1098    } else if trimmed.starts_with('/') {
1099        trimmed.to_string()
1100    } else {
1101        format!("/{trimmed}")
1102    }
1103}
1104
1105fn quote_auth_value(value: &str) -> String {
1106    value.replace('\\', "\\\\").replace('"', "\\\"")
1107}
1108
1109fn default_port_for_scheme(scheme: &str) -> Option<u16> {
1110    match scheme {
1111        "http" | "ws" => Some(80),
1112        "https" | "wss" => Some(443),
1113        _ => None,
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    fn metadata(
1122        issuer: &str,
1123        registration_endpoint: Option<&str>,
1124    ) -> OAuthAuthorizationServerMetadata {
1125        OAuthAuthorizationServerMetadata {
1126            issuer: issuer.to_string(),
1127            authorization_endpoint: format!("{issuer}/authorize"),
1128            token_endpoint: format!("{issuer}/token"),
1129            registration_endpoint: registration_endpoint.map(ToString::to_string),
1130            token_endpoint_auth_methods_supported: vec!["none".to_string()],
1131            code_challenge_methods_supported: vec!["S256".to_string()],
1132            scopes_supported: Vec::new(),
1133            client_id_metadata_document_supported: false,
1134            authorization_response_iss_parameter_supported: false,
1135            extra: BTreeMap::new(),
1136        }
1137    }
1138
1139    #[test]
1140    fn canonical_resource_indicator_strips_trailing_slash() {
1141        assert_eq!(
1142            canonical_resource_indicator("https://mcp.example.com/").unwrap(),
1143            "https://mcp.example.com"
1144        );
1145        assert_eq!(
1146            canonical_resource_indicator("https://mcp.example.com").unwrap(),
1147            "https://mcp.example.com"
1148        );
1149        assert_eq!(
1150            canonical_resource_indicator("https://mcp.example.com/mcp/").unwrap(),
1151            "https://mcp.example.com/mcp"
1152        );
1153    }
1154
1155    #[test]
1156    fn bearer_error_and_insufficient_scope_detection() {
1157        let challenges =
1158            parse_www_authenticate(r#"Bearer error="insufficient_scope", scope="repo admin""#);
1159        let challenge = challenges.first().expect("one Bearer challenge");
1160        assert_eq!(challenge.bearer_error(), Some("insufficient_scope"));
1161        assert_eq!(challenge.bearer_scope(), Some("repo admin"));
1162        assert!(challenge.is_insufficient_scope());
1163
1164        // A plain 401 Bearer challenge with no error param is not a scope gap.
1165        let plain = parse_www_authenticate(r#"Bearer scope="repo""#);
1166        let plain = plain.first().expect("one Bearer challenge");
1167        assert_eq!(plain.bearer_error(), None);
1168        assert!(!plain.is_insufficient_scope());
1169
1170        // A non-Bearer scheme never reports a Bearer error.
1171        let basic = parse_www_authenticate(r#"Basic realm="x", error="insufficient_scope""#);
1172        let basic = basic.first().expect("one challenge");
1173        assert_eq!(basic.bearer_error(), None);
1174        assert!(!basic.is_insufficient_scope());
1175    }
1176
1177    #[test]
1178    fn canonical_resource_indicator_strips_fragment_and_query() {
1179        assert_eq!(
1180            canonical_resource_indicator("https://mcp.example.com/mcp?token=secret#section")
1181                .unwrap(),
1182            "https://mcp.example.com/mcp"
1183        );
1184    }
1185
1186    #[test]
1187    fn canonical_resource_indicator_lowercases_scheme_and_host() {
1188        assert_eq!(
1189            canonical_resource_indicator("HTTPS://MCP.Example.COM/Path").unwrap(),
1190            "https://mcp.example.com/Path"
1191        );
1192    }
1193
1194    #[test]
1195    fn canonical_resource_indicator_drops_default_ports() {
1196        assert_eq!(
1197            canonical_resource_indicator("https://mcp.example.com:443/").unwrap(),
1198            "https://mcp.example.com"
1199        );
1200        assert_eq!(
1201            canonical_resource_indicator("http://mcp.example.com:80").unwrap(),
1202            "http://mcp.example.com"
1203        );
1204        assert_eq!(
1205            canonical_resource_indicator("https://mcp.example.com:8443/mcp").unwrap(),
1206            "https://mcp.example.com:8443/mcp"
1207        );
1208    }
1209
1210    #[test]
1211    fn canonical_resource_indicator_preserves_non_empty_path_segments() {
1212        assert_eq!(
1213            canonical_resource_indicator("https://example.com/mcp/notion/").unwrap(),
1214            "https://example.com/mcp/notion"
1215        );
1216    }
1217
1218    #[test]
1219    fn oauth_authorization_url_includes_resource_indicator() {
1220        let url = build_oauth_authorization_url(OAuthAuthorizationUrlOptions {
1221            authorization_endpoint: "https://auth.example.com/authorize",
1222            client_id: "client-123",
1223            redirect_uri: "http://127.0.0.1:9783/oauth/callback",
1224            state: "state-abc",
1225            code_challenge: "challenge-xyz",
1226            resource: "https://mcp.example.com/mcp",
1227            scopes: Some("mcp.read"),
1228        })
1229        .unwrap();
1230        let params = url.query_pairs().collect::<BTreeMap<_, _>>();
1231        assert_eq!(
1232            params.get("resource").map(|value| value.as_ref()),
1233            Some("https://mcp.example.com/mcp")
1234        );
1235        assert_eq!(
1236            params.get("scope").map(|value| value.as_ref()),
1237            Some("mcp.read")
1238        );
1239    }
1240
1241    #[test]
1242    fn token_forms_include_resource_indicator() {
1243        let code_form = authorization_code_token_form(OAuthAuthorizationCodeTokenForm {
1244            client_id: "client-123",
1245            redirect_uri: "http://127.0.0.1:9783/oauth/callback",
1246            code: "code-abc",
1247            code_verifier: "verifier-xyz",
1248            resource: "https://mcp.example.com/mcp",
1249            scopes: Some("mcp.read"),
1250        });
1251        assert!(code_form.contains(&("resource", "https://mcp.example.com/mcp".to_string())));
1252        assert!(code_form.contains(&("scope", "mcp.read".to_string())));
1253
1254        let refresh_form = refresh_token_form(OAuthRefreshTokenForm {
1255            client_id: "client-123",
1256            refresh_token: "refresh-abc",
1257            resource: "https://mcp.example.com/mcp",
1258        });
1259        assert!(refresh_form.contains(&("resource", "https://mcp.example.com/mcp".to_string())));
1260    }
1261
1262    #[test]
1263    fn canonical_resource_indicator_rejects_invalid_url() {
1264        assert!(canonical_resource_indicator("not a url").is_err());
1265    }
1266
1267    #[test]
1268    fn parses_bearer_challenge_resource_metadata_and_scope() {
1269        let challenges = parse_www_authenticate(
1270            r#"Bearer realm="mcp", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read files:write""#,
1271        );
1272        assert_eq!(challenges.len(), 1);
1273        let challenge = &challenges[0];
1274        assert_eq!(
1275            challenge.bearer_resource_metadata(),
1276            Some("https://mcp.example/.well-known/oauth-protected-resource")
1277        );
1278        assert_eq!(
1279            split_scope_value(challenge.bearer_scope()),
1280            vec!["files:read", "files:write"]
1281        );
1282    }
1283
1284    #[test]
1285    fn parses_multiple_www_authenticate_challenges() {
1286        let challenge = bearer_challenge_from_headers([
1287            r#"Basic realm="old""#,
1288            r#"Bearer error="insufficient_scope", scope="admin", resource_metadata="https://mcp.example/meta""#,
1289        ])
1290        .expect("bearer challenge");
1291        assert_eq!(
1292            challenge.params.get("error").map(String::as_str),
1293            Some("insufficient_scope")
1294        );
1295        assert_eq!(
1296            challenge.bearer_resource_metadata(),
1297            Some("https://mcp.example/meta")
1298        );
1299    }
1300
1301    #[test]
1302    fn bearer_challenge_selection_prefers_resource_metadata() {
1303        let challenge = bearer_challenge_from_headers([
1304            r#"Bearer realm="old", Bearer resource_metadata="https://mcp.example/meta""#,
1305        ])
1306        .expect("bearer challenge");
1307        assert_eq!(
1308            challenge.bearer_resource_metadata(),
1309            Some("https://mcp.example/meta")
1310        );
1311    }
1312
1313    #[test]
1314    fn authorization_server_candidates_include_oidc_path_appending() {
1315        let issuer = Url::parse("https://auth.example.com/tenant1").unwrap();
1316        let candidates = authorization_server_metadata_candidates(&issuer);
1317        let urls = candidates
1318            .iter()
1319            .map(|candidate| candidate.url.as_str())
1320            .collect::<Vec<_>>();
1321        assert_eq!(
1322            urls,
1323            vec![
1324                "https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
1325                "https://auth.example.com/.well-known/openid-configuration/tenant1",
1326                "https://auth.example.com/tenant1/.well-known/openid-configuration",
1327            ]
1328        );
1329    }
1330
1331    #[test]
1332    fn validates_authorization_server_issuer_without_normalization() {
1333        let mut metadata = metadata("https://auth.example.com", None);
1334        validate_authorization_server_issuer("https://auth.example.com", &metadata).unwrap();
1335        metadata.issuer = "https://auth.example.com/".to_string();
1336        let err = validate_authorization_server_issuer("https://auth.example.com", &metadata)
1337            .expect_err("issuer mismatch");
1338        assert!(err.to_string().contains("issuer mismatch"));
1339    }
1340
1341    #[test]
1342    fn authorization_response_issuer_validation_follows_rfc9207_advertisement() {
1343        let mut metadata = metadata("https://auth.example.com", None);
1344        assert!(validate_authorization_response_issuer(&metadata, None).is_ok());
1345        assert!(
1346            validate_authorization_response_issuer(&metadata, Some("https://other.example"))
1347                .is_err()
1348        );
1349        metadata.authorization_response_iss_parameter_supported = true;
1350        assert!(validate_authorization_response_issuer(&metadata, None).is_err());
1351        assert!(validate_authorization_response_issuer(
1352            &metadata,
1353            Some("https://auth.example.com")
1354        )
1355        .is_ok());
1356    }
1357
1358    #[test]
1359    fn scope_selection_prefers_challenge_scope_then_metadata_scope() {
1360        assert_eq!(
1361            select_oauth_scopes(Some("files:read files:write files:read"), &[]),
1362            vec!["files:read", "files:write"]
1363        );
1364        assert_eq!(
1365            select_oauth_scopes(None, &["basic".to_string(), "profile".to_string()]),
1366            vec!["basic", "profile"]
1367        );
1368    }
1369
1370    #[test]
1371    fn client_registration_mode_selection_is_explicit() {
1372        let mut meta = metadata(
1373            "https://auth.example.com",
1374            Some("https://auth.example.com/reg"),
1375        );
1376        assert_eq!(
1377            select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
1378            OAuthClientRegistrationMode::DynamicClientRegistration
1379        );
1380        assert_eq!(
1381            select_client_registration_mode(
1382                &meta,
1383                OAuthClientRegistrationOptions {
1384                    client_id: Some("static-client"),
1385                    ..OAuthClientRegistrationOptions::default()
1386                },
1387            ),
1388            OAuthClientRegistrationMode::PreRegistered
1389        );
1390        meta.client_id_metadata_document_supported = true;
1391        assert_eq!(
1392            select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
1393            OAuthClientRegistrationMode::ClientIdMetadataDocument
1394        );
1395        assert_eq!(
1396            select_client_registration_mode(
1397                &meta,
1398                OAuthClientRegistrationOptions {
1399                    client_id: Some("https://client.example/oauth/client.json"),
1400                    ..OAuthClientRegistrationOptions::default()
1401                },
1402            ),
1403            OAuthClientRegistrationMode::ClientIdMetadataDocument
1404        );
1405    }
1406
1407    #[test]
1408    fn oauth_client_auth_prefers_cimd_before_dcr() {
1409        let mut meta = metadata(
1410            "https://auth.example.com",
1411            Some("https://auth.example.com/reg"),
1412        );
1413        meta.client_id_metadata_document_supported = true;
1414        let selection = select_oauth_client_auth(&meta, OAuthClientAuthOptions::default()).unwrap();
1415        assert_eq!(selection.mode, OAuthClientAuthMode::Cimd);
1416        assert_eq!(
1417            selection.client_id,
1418            Some(DEFAULT_MCP_OAUTH_CLIENT_ID_METADATA_DOCUMENT_URL)
1419        );
1420    }
1421
1422    #[test]
1423    fn oauth_client_auth_falls_back_to_dcr_without_cimd() {
1424        let meta = metadata(
1425            "https://auth.example.com",
1426            Some("https://auth.example.com/reg"),
1427        );
1428        let selection = select_oauth_client_auth(&meta, OAuthClientAuthOptions::default()).unwrap();
1429        assert_eq!(selection.mode, OAuthClientAuthMode::Dcr);
1430        assert_eq!(selection.client_id, None);
1431    }
1432
1433    #[test]
1434    fn oauth_client_auth_accepts_byo_secret_references_as_byo() {
1435        let meta = metadata("https://auth.example.com", None);
1436        let selection = select_oauth_client_auth(
1437            &meta,
1438            OAuthClientAuthOptions {
1439                mode: Some(OAuthClientAuthMode::Byo),
1440                client_id: Some("registered-client"),
1441                client_secret: Some("secret-from-store"),
1442                ..OAuthClientAuthOptions::default()
1443            },
1444        )
1445        .unwrap();
1446        assert_eq!(selection.mode, OAuthClientAuthMode::Byo);
1447        assert_eq!(selection.client_id, Some("registered-client"));
1448    }
1449
1450    #[test]
1451    fn explicit_cimd_auth_requires_metadata_document_url() {
1452        let mut meta = metadata("https://auth.example.com", None);
1453        meta.client_id_metadata_document_supported = true;
1454        let error = select_oauth_client_auth(
1455            &meta,
1456            OAuthClientAuthOptions {
1457                mode: Some(OAuthClientAuthMode::Cimd),
1458                client_id: Some("registered-client"),
1459                ..OAuthClientAuthOptions::default()
1460            },
1461        )
1462        .expect_err("invalid CIMD client id");
1463        assert!(error.contains("metadata document URL"));
1464    }
1465
1466    #[test]
1467    fn dynamic_registration_body_marks_loopback_clients_native() {
1468        let body = dynamic_client_registration_body(
1469            "Harn CLI",
1470            ["http://127.0.0.1:49152/oauth/callback"],
1471            Some("mcp.read"),
1472        );
1473        assert_eq!(body["application_type"], "native");
1474        assert_eq!(body["token_endpoint_auth_method"], "none");
1475        assert_eq!(body["grant_types"][1], "refresh_token");
1476        assert_eq!(body["scope"], "mcp.read");
1477    }
1478
1479    #[test]
1480    fn token_refresh_binding_rejects_cross_issuer_reuse() {
1481        assert!(
1482            validate_issuer_binding("https://issuer-a.example", "https://issuer-a.example").is_ok()
1483        );
1484        assert!(
1485            validate_issuer_binding("https://issuer-a.example", "https://issuer-b.example")
1486                .is_err()
1487        );
1488    }
1489}