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