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";
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct WwwAuthenticateChallenge {
23    pub scheme: String,
24    pub params: BTreeMap<String, String>,
25}
26
27impl WwwAuthenticateChallenge {
28    pub fn bearer_resource_metadata(&self) -> Option<&str> {
29        self.scheme
30            .eq_ignore_ascii_case("bearer")
31            .then(|| self.params.get("resource_metadata").map(String::as_str))
32            .flatten()
33    }
34
35    pub fn bearer_scope(&self) -> Option<&str> {
36        self.scheme
37            .eq_ignore_ascii_case("bearer")
38            .then(|| self.params.get("scope").map(String::as_str))
39            .flatten()
40    }
41}
42
43#[derive(Clone, Debug, Default, Deserialize, Serialize)]
44pub struct OAuthProtectedResourceMetadata {
45    #[serde(default)]
46    pub resource: Option<String>,
47    #[serde(default)]
48    pub authorization_servers: Vec<String>,
49    #[serde(default)]
50    pub scopes_supported: Vec<String>,
51    #[serde(default)]
52    pub bearer_methods_supported: Vec<String>,
53    #[serde(flatten)]
54    pub extra: BTreeMap<String, JsonValue>,
55}
56
57#[derive(Clone, Debug, Deserialize, Serialize)]
58pub struct OAuthAuthorizationServerMetadata {
59    pub issuer: String,
60    pub authorization_endpoint: String,
61    pub token_endpoint: String,
62    #[serde(default)]
63    pub registration_endpoint: Option<String>,
64    #[serde(default)]
65    pub token_endpoint_auth_methods_supported: Vec<String>,
66    #[serde(default)]
67    pub code_challenge_methods_supported: Vec<String>,
68    #[serde(default)]
69    pub scopes_supported: Vec<String>,
70    #[serde(default)]
71    pub client_id_metadata_document_supported: bool,
72    #[serde(default)]
73    pub authorization_response_iss_parameter_supported: bool,
74    #[serde(flatten)]
75    pub extra: BTreeMap<String, JsonValue>,
76}
77
78#[derive(Clone, Debug, Default, Deserialize, Serialize)]
79pub struct OAuthDynamicClientRegistrationResponse {
80    pub client_id: String,
81    #[serde(default)]
82    pub client_secret: Option<String>,
83    #[serde(default)]
84    pub token_endpoint_auth_method: Option<String>,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "snake_case")]
89pub enum OAuthAuthorizationServerMetadataKind {
90    OAuthAuthorizationServer,
91    OpenIdConfiguration,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct OAuthAuthorizationServerMetadataCandidate {
96    pub url: Url,
97    pub kind: OAuthAuthorizationServerMetadataKind,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "snake_case")]
102pub enum OAuthClientRegistrationMode {
103    PreRegistered,
104    ClientIdMetadataDocument,
105    DynamicClientRegistration,
106    Manual,
107}
108
109impl OAuthClientRegistrationMode {
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::PreRegistered => "pre_registered",
113            Self::ClientIdMetadataDocument => "client_id_metadata_document",
114            Self::DynamicClientRegistration => "dynamic_client_registration",
115            Self::Manual => "manual",
116        }
117    }
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "lowercase")]
122pub enum OAuthApplicationType {
123    Native,
124    Web,
125}
126
127impl OAuthApplicationType {
128    pub fn as_str(self) -> &'static str {
129        match self {
130            Self::Native => "native",
131            Self::Web => "web",
132        }
133    }
134}
135
136#[derive(Clone, Debug, Default)]
137pub struct OAuthClientRegistrationOptions<'a> {
138    pub client_id: Option<&'a str>,
139    pub client_secret: Option<&'a str>,
140    pub client_id_metadata_document_url: Option<&'a str>,
141}
142
143#[derive(Clone, Debug)]
144pub struct McpOAuthDiscovery {
145    pub protected_resource_metadata_url: Url,
146    pub protected_resource_metadata: OAuthProtectedResourceMetadata,
147    pub authorization_server_issuer: String,
148    pub authorization_server_metadata_url: Url,
149    pub authorization_server_metadata_kind: OAuthAuthorizationServerMetadataKind,
150    pub authorization_server_metadata: OAuthAuthorizationServerMetadata,
151    pub challenge: Option<WwwAuthenticateChallenge>,
152    pub scopes: Vec<String>,
153}
154
155#[derive(Debug)]
156pub enum McpOAuthDiscoveryError {
157    InvalidResourceUrl(String),
158    InvalidResourceMetadataUrl(String),
159    InvalidAuthorizationServerUrl { issuer: String, error: String },
160    ProtectedResourceMetadataNotFound,
161    MissingAuthorizationServer,
162    AuthorizationServerMetadataNotFound { issuer: String },
163    AuthorizationServerIssuerMismatch { expected: String, actual: String },
164    AuthorizationServerIssuerMissing { expected: String },
165    Json { url: String, error: String },
166}
167
168impl fmt::Display for McpOAuthDiscoveryError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Self::InvalidResourceUrl(error) => write!(f, "invalid MCP resource URL: {error}"),
172            Self::InvalidResourceMetadataUrl(error) => {
173                write!(f, "invalid resource_metadata URL in WWW-Authenticate: {error}")
174            }
175            Self::InvalidAuthorizationServerUrl { issuer, error } => {
176                write!(f, "invalid authorization server URL '{issuer}': {error}")
177            }
178            Self::ProtectedResourceMetadataNotFound => {
179                write!(f, "OAuth protected resource metadata not found")
180            }
181            Self::MissingAuthorizationServer => write!(
182                f,
183                "OAuth protected resource metadata did not advertise an authorization server"
184            ),
185            Self::AuthorizationServerMetadataNotFound { issuer } => {
186                write!(f, "authorization server metadata not found for issuer '{issuer}'")
187            }
188            Self::AuthorizationServerIssuerMismatch { expected, actual } => write!(
189                f,
190                "authorization server metadata issuer mismatch: expected '{expected}', got '{actual}'"
191            ),
192            Self::AuthorizationServerIssuerMissing { expected } => write!(
193                f,
194                "authorization server metadata for '{expected}' did not include an issuer"
195            ),
196            Self::Json { url, error } => write!(f, "failed to parse {url}: {error}"),
197        }
198    }
199}
200
201impl std::error::Error for McpOAuthDiscoveryError {}
202
203pub fn parse_www_authenticate(header: &str) -> Vec<WwwAuthenticateChallenge> {
204    let mut challenges = Vec::<WwwAuthenticateChallenge>::new();
205    let mut current: Option<WwwAuthenticateChallenge> = None;
206
207    for segment in split_challenge_segments(header) {
208        let segment = segment.trim();
209        if segment.is_empty() {
210            continue;
211        }
212        let (first, rest) = split_first_token(segment);
213        let starts_challenge = !first.contains('=');
214        if starts_challenge {
215            if let Some(challenge) = current.take() {
216                challenges.push(challenge);
217            }
218            let mut challenge = WwwAuthenticateChallenge {
219                scheme: first.to_string(),
220                params: BTreeMap::new(),
221            };
222            if !rest.trim().is_empty() {
223                parse_auth_param(rest.trim(), &mut challenge.params);
224            }
225            current = Some(challenge);
226        } else if let Some(challenge) = current.as_mut() {
227            parse_auth_param(segment, &mut challenge.params);
228        }
229    }
230
231    if let Some(challenge) = current {
232        challenges.push(challenge);
233    }
234    challenges
235}
236
237pub fn parse_www_authenticate_headers<'a>(
238    headers: impl IntoIterator<Item = &'a str>,
239) -> Vec<WwwAuthenticateChallenge> {
240    headers
241        .into_iter()
242        .flat_map(parse_www_authenticate)
243        .collect()
244}
245
246pub fn bearer_challenge_from_headers<'a>(
247    headers: impl IntoIterator<Item = &'a str>,
248) -> Option<WwwAuthenticateChallenge> {
249    let mut first_bearer = None;
250    for challenge in parse_www_authenticate_headers(headers) {
251        if !challenge.scheme.eq_ignore_ascii_case("bearer") {
252            continue;
253        }
254        if challenge.bearer_resource_metadata().is_some() {
255            return Some(challenge);
256        }
257        first_bearer.get_or_insert(challenge);
258    }
259    first_bearer
260}
261
262pub fn protected_resource_metadata_candidates(resource_url: &Url) -> Vec<Url> {
263    let mut urls = Vec::new();
264    let path = resource_url
265        .path()
266        .trim_start_matches('/')
267        .trim_end_matches('/');
268    if !path.is_empty() {
269        let mut url = resource_url.clone();
270        url.set_path(&format!(
271            "{OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH}/{path}"
272        ));
273        url.set_query(None);
274        url.set_fragment(None);
275        urls.push(url);
276    }
277    let mut root = resource_url.clone();
278    root.set_path(OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH);
279    root.set_query(None);
280    root.set_fragment(None);
281    urls.push(root);
282    urls
283}
284
285pub fn protected_resource_metadata_path(mcp_path: &str) -> String {
286    let mcp_path = normalize_path(mcp_path);
287    if mcp_path == "/" {
288        OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH.to_string()
289    } else {
290        format!("{OAUTH_PROTECTED_RESOURCE_WELL_KNOWN_PATH}{mcp_path}")
291    }
292}
293
294pub fn authorization_server_metadata_candidates(
295    auth_server_url: &Url,
296) -> Vec<OAuthAuthorizationServerMetadataCandidate> {
297    let mut urls = Vec::new();
298    let path = auth_server_url.path();
299    let has_path = !path.is_empty() && path != "/";
300    if has_path {
301        let trimmed = path.trim_start_matches('/');
302
303        let mut oauth = auth_server_url.clone();
304        oauth.set_path(&format!(
305            "{OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH}/{trimmed}"
306        ));
307        oauth.set_query(None);
308        oauth.set_fragment(None);
309        urls.push(OAuthAuthorizationServerMetadataCandidate {
310            url: oauth,
311            kind: OAuthAuthorizationServerMetadataKind::OAuthAuthorizationServer,
312        });
313
314        let mut oidc_inserted = auth_server_url.clone();
315        oidc_inserted.set_path(&format!("{OIDC_CONFIGURATION_WELL_KNOWN_PATH}/{trimmed}"));
316        oidc_inserted.set_query(None);
317        oidc_inserted.set_fragment(None);
318        urls.push(OAuthAuthorizationServerMetadataCandidate {
319            url: oidc_inserted,
320            kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
321        });
322
323        let mut oidc_appended = auth_server_url.clone();
324        let base = path.trim_end_matches('/');
325        oidc_appended.set_path(&format!("{base}{OIDC_CONFIGURATION_WELL_KNOWN_PATH}"));
326        oidc_appended.set_query(None);
327        oidc_appended.set_fragment(None);
328        urls.push(OAuthAuthorizationServerMetadataCandidate {
329            url: oidc_appended,
330            kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
331        });
332        return urls;
333    }
334
335    let mut oauth = auth_server_url.clone();
336    oauth.set_path(OAUTH_AUTHORIZATION_SERVER_WELL_KNOWN_PATH);
337    oauth.set_query(None);
338    oauth.set_fragment(None);
339    urls.push(OAuthAuthorizationServerMetadataCandidate {
340        url: oauth,
341        kind: OAuthAuthorizationServerMetadataKind::OAuthAuthorizationServer,
342    });
343
344    let mut oidc = auth_server_url.clone();
345    oidc.set_path(OIDC_CONFIGURATION_WELL_KNOWN_PATH);
346    oidc.set_query(None);
347    oidc.set_fragment(None);
348    urls.push(OAuthAuthorizationServerMetadataCandidate {
349        url: oidc,
350        kind: OAuthAuthorizationServerMetadataKind::OpenIdConfiguration,
351    });
352    urls
353}
354
355pub async fn discover_mcp_oauth(
356    client: &reqwest::Client,
357    resource: &str,
358) -> Result<McpOAuthDiscovery, McpOAuthDiscoveryError> {
359    let resource_url = Url::parse(resource)
360        .map_err(|error| McpOAuthDiscoveryError::InvalidResourceUrl(error.to_string()))?;
361    discover_mcp_oauth_from_url(client, &resource_url).await
362}
363
364pub async fn discover_mcp_oauth_from_url(
365    client: &reqwest::Client,
366    resource_url: &Url,
367) -> Result<McpOAuthDiscovery, McpOAuthDiscoveryError> {
368    let challenge = fetch_resource_challenge(client, resource_url).await;
369    let challenged_metadata_url = challenge
370        .as_ref()
371        .and_then(WwwAuthenticateChallenge::bearer_resource_metadata)
372        .map(|url| {
373            Url::parse(url).map_err(|error| {
374                McpOAuthDiscoveryError::InvalidResourceMetadataUrl(error.to_string())
375            })
376        })
377        .transpose()?;
378
379    let metadata_candidates = challenged_metadata_url
380        .into_iter()
381        .chain(protected_resource_metadata_candidates(resource_url))
382        .collect::<Vec<_>>();
383    let (protected_resource_metadata_url, protected_resource_metadata) =
384        fetch_first_json::<OAuthProtectedResourceMetadata>(client, &metadata_candidates)
385            .await?
386            .ok_or(McpOAuthDiscoveryError::ProtectedResourceMetadataNotFound)?;
387    let authorization_server_issuer = protected_resource_metadata
388        .authorization_servers
389        .first()
390        .cloned()
391        .ok_or(McpOAuthDiscoveryError::MissingAuthorizationServer)?;
392    let auth_server_url = Url::parse(&authorization_server_issuer).map_err(|error| {
393        McpOAuthDiscoveryError::InvalidAuthorizationServerUrl {
394            issuer: authorization_server_issuer.clone(),
395            error: error.to_string(),
396        }
397    })?;
398    let (authorization_server_metadata_url, authorization_server_metadata_kind, metadata) =
399        fetch_authorization_server_metadata(client, &authorization_server_issuer, &auth_server_url)
400            .await?;
401    let scopes = select_oauth_scopes(
402        challenge
403            .as_ref()
404            .and_then(WwwAuthenticateChallenge::bearer_scope),
405        &protected_resource_metadata.scopes_supported,
406    );
407    Ok(McpOAuthDiscovery {
408        protected_resource_metadata_url,
409        protected_resource_metadata,
410        authorization_server_issuer,
411        authorization_server_metadata_url,
412        authorization_server_metadata_kind,
413        authorization_server_metadata: metadata,
414        challenge,
415        scopes,
416    })
417}
418
419pub async fn fetch_authorization_server_metadata(
420    client: &reqwest::Client,
421    expected_issuer: &str,
422    auth_server_url: &Url,
423) -> Result<
424    (
425        Url,
426        OAuthAuthorizationServerMetadataKind,
427        OAuthAuthorizationServerMetadata,
428    ),
429    McpOAuthDiscoveryError,
430> {
431    let candidates = authorization_server_metadata_candidates(auth_server_url);
432    for candidate in candidates {
433        let Some(metadata) =
434            fetch_json::<OAuthAuthorizationServerMetadata>(client, &candidate.url).await?
435        else {
436            continue;
437        };
438        validate_authorization_server_issuer(expected_issuer, &metadata)?;
439        return Ok((candidate.url, candidate.kind, metadata));
440    }
441    Err(
442        McpOAuthDiscoveryError::AuthorizationServerMetadataNotFound {
443            issuer: expected_issuer.to_string(),
444        },
445    )
446}
447
448pub fn validate_authorization_server_issuer(
449    expected_issuer: &str,
450    metadata: &OAuthAuthorizationServerMetadata,
451) -> Result<(), McpOAuthDiscoveryError> {
452    if metadata.issuer.is_empty() {
453        return Err(McpOAuthDiscoveryError::AuthorizationServerIssuerMissing {
454            expected: expected_issuer.to_string(),
455        });
456    }
457    if metadata.issuer != expected_issuer {
458        return Err(McpOAuthDiscoveryError::AuthorizationServerIssuerMismatch {
459            expected: expected_issuer.to_string(),
460            actual: metadata.issuer.clone(),
461        });
462    }
463    Ok(())
464}
465
466pub fn validate_authorization_response_issuer(
467    metadata: &OAuthAuthorizationServerMetadata,
468    response_issuer: Option<&str>,
469) -> Result<(), String> {
470    match (
471        metadata.authorization_response_iss_parameter_supported,
472        response_issuer,
473    ) {
474        (true, Some(actual)) if actual == metadata.issuer => Ok(()),
475        (true, Some(actual)) => Err(format!(
476            "authorization response issuer mismatch: expected '{}', got '{}'",
477            metadata.issuer, actual
478        )),
479        (true, None) => Err(
480            "authorization response did not include required RFC 9207 iss parameter".to_string(),
481        ),
482        (false, Some(actual)) if actual == metadata.issuer => Ok(()),
483        (false, Some(actual)) => Err(format!(
484            "authorization response issuer mismatch: expected '{}', got '{}'",
485            metadata.issuer, actual
486        )),
487        (false, None) => Ok(()),
488    }
489}
490
491pub fn validate_issuer_binding(stored_issuer: &str, current_issuer: &str) -> Result<(), String> {
492    if stored_issuer == current_issuer {
493        Ok(())
494    } else {
495        Err(format!(
496            "stored OAuth credentials are bound to issuer '{stored_issuer}', but the MCP resource now advertises '{current_issuer}'"
497        ))
498    }
499}
500
501pub fn select_oauth_scopes(
502    challenge_scope: Option<&str>,
503    scopes_supported: &[String],
504) -> Vec<String> {
505    let challenged = split_scope_value(challenge_scope);
506    if challenged.is_empty() {
507        dedupe_scopes(scopes_supported.iter().map(String::as_str))
508    } else {
509        challenged
510    }
511}
512
513pub fn accumulate_oauth_scopes<'a>(
514    existing: impl IntoIterator<Item = &'a str>,
515    challenged: impl IntoIterator<Item = &'a str>,
516) -> Vec<String> {
517    dedupe_scopes(existing.into_iter().chain(challenged))
518}
519
520pub fn split_scope_value(value: Option<&str>) -> Vec<String> {
521    dedupe_scopes(
522        value
523            .unwrap_or_default()
524            .split_whitespace()
525            .map(str::trim)
526            .filter(|scope| !scope.is_empty()),
527    )
528}
529
530pub fn select_client_registration_mode(
531    metadata: &OAuthAuthorizationServerMetadata,
532    options: OAuthClientRegistrationOptions<'_>,
533) -> OAuthClientRegistrationMode {
534    if let Some(client_id) = options.client_id {
535        if options.client_secret.is_none() && is_client_id_metadata_document_url(client_id) {
536            return OAuthClientRegistrationMode::ClientIdMetadataDocument;
537        }
538        return OAuthClientRegistrationMode::PreRegistered;
539    }
540    if options
541        .client_id_metadata_document_url
542        .is_some_and(is_client_id_metadata_document_url)
543        && metadata.client_id_metadata_document_supported
544    {
545        return OAuthClientRegistrationMode::ClientIdMetadataDocument;
546    }
547    if metadata.registration_endpoint.is_some() {
548        return OAuthClientRegistrationMode::DynamicClientRegistration;
549    }
550    OAuthClientRegistrationMode::Manual
551}
552
553pub fn is_client_id_metadata_document_url(client_id: &str) -> bool {
554    Url::parse(client_id)
555        .ok()
556        .filter(|url| url.scheme() == "https")
557        .and_then(|url| {
558            let path = url.path().trim_matches('/');
559            (!path.is_empty()).then_some(())
560        })
561        .is_some()
562}
563
564pub fn ensure_pkce_s256_supported(
565    metadata: &OAuthAuthorizationServerMetadata,
566) -> Result<(), String> {
567    let methods = &metadata.code_challenge_methods_supported;
568    if methods.is_empty() || methods.iter().any(|method| method == "S256") {
569        return Ok(());
570    }
571    Err("Authorization server does not advertise PKCE S256 support".to_string())
572}
573
574pub fn determine_token_endpoint_auth_method(
575    metadata: &OAuthAuthorizationServerMetadata,
576    client_secret: Option<&str>,
577) -> Result<String, String> {
578    let methods = &metadata.token_endpoint_auth_methods_supported;
579    if client_secret.is_some() {
580        if methods.is_empty() || methods.iter().any(|method| method == "client_secret_post") {
581            return Ok("client_secret_post".to_string());
582        }
583        if methods.iter().any(|method| method == "client_secret_basic") {
584            return Ok("client_secret_basic".to_string());
585        }
586        return Err(
587            "Authorization server does not support client_secret_post or client_secret_basic"
588                .to_string(),
589        );
590    }
591
592    if methods.is_empty() || methods.iter().any(|method| method == "none") {
593        return Ok("none".to_string());
594    }
595    Err("Authorization server requires client authentication. Supply --client-secret or configure a registered client.".to_string())
596}
597
598pub fn validate_token_endpoint_auth_method(method: &str) -> Result<(), String> {
599    match method {
600        "none" | "client_secret_post" | "client_secret_basic" => Ok(()),
601        other => Err(format!(
602            "unsupported token auth method '{other}'; expected none, client_secret_post, or client_secret_basic"
603        )),
604    }
605}
606
607pub fn application_type_for_redirect_uris<'a>(
608    redirect_uris: impl IntoIterator<Item = &'a str>,
609) -> OAuthApplicationType {
610    if redirect_uris.into_iter().all(redirect_uri_is_native) {
611        OAuthApplicationType::Native
612    } else {
613        OAuthApplicationType::Web
614    }
615}
616
617pub fn dynamic_client_registration_body<'a>(
618    client_name: &str,
619    redirect_uris: impl IntoIterator<Item = &'a str>,
620    scopes: Option<&str>,
621) -> JsonValue {
622    let redirect_uris = redirect_uris
623        .into_iter()
624        .map(ToString::to_string)
625        .collect::<Vec<_>>();
626    let application_type =
627        application_type_for_redirect_uris(redirect_uris.iter().map(String::as_str));
628    let mut body = json!({
629        "client_name": client_name,
630        "redirect_uris": redirect_uris,
631        "grant_types": ["authorization_code", "refresh_token"],
632        "response_types": ["code"],
633        "token_endpoint_auth_method": "none",
634        "application_type": application_type.as_str(),
635    });
636    if let Some(scopes) = scopes.filter(|scopes| !scopes.trim().is_empty()) {
637        body["scope"] = json!(scopes);
638    }
639    body
640}
641
642pub fn bearer_challenge_value(
643    resource_metadata_url: &str,
644    scopes: &[String],
645    error: Option<BearerChallengeError<'_>>,
646) -> String {
647    let mut parts = vec![format!(
648        "resource_metadata=\"{}\"",
649        quote_auth_value(resource_metadata_url)
650    )];
651    if !scopes.is_empty() {
652        parts.push(format!("scope=\"{}\"", quote_auth_value(&scopes.join(" "))));
653    }
654    if let Some(error) = error {
655        parts.insert(0, format!("error=\"{}\"", quote_auth_value(error.code)));
656        if let Some(description) = error.description {
657            parts.push(format!(
658                "error_description=\"{}\"",
659                quote_auth_value(description)
660            ));
661        }
662    }
663    format!("Bearer {}", parts.join(", "))
664}
665
666#[derive(Clone, Copy, Debug, PartialEq, Eq)]
667pub struct BearerChallengeError<'a> {
668    pub code: &'a str,
669    pub description: Option<&'a str>,
670}
671
672fn split_challenge_segments(header: &str) -> Vec<&str> {
673    let mut segments = Vec::new();
674    let mut start = 0;
675    let mut in_quote = false;
676    let mut escaped = false;
677    for (index, character) in header.char_indices() {
678        if escaped {
679            escaped = false;
680            continue;
681        }
682        match character {
683            '\\' if in_quote => escaped = true,
684            '"' => in_quote = !in_quote,
685            ',' if !in_quote => {
686                segments.push(&header[start..index]);
687                start = index + 1;
688            }
689            _ => {}
690        }
691    }
692    segments.push(&header[start..]);
693    segments
694}
695
696fn split_first_token(segment: &str) -> (&str, &str) {
697    let trimmed = segment.trim_start();
698    match trimmed.find(char::is_whitespace) {
699        Some(index) => (&trimmed[..index], &trimmed[index..]),
700        None => (trimmed, ""),
701    }
702}
703
704fn parse_auth_param(segment: &str, params: &mut BTreeMap<String, String>) {
705    let Some((key, raw_value)) = segment.split_once('=') else {
706        return;
707    };
708    let key = key.trim().to_ascii_lowercase();
709    if key.is_empty() {
710        return;
711    }
712    params.insert(key, parse_auth_value(raw_value.trim()));
713}
714
715fn parse_auth_value(raw_value: &str) -> String {
716    let Some(stripped) = raw_value
717        .strip_prefix('"')
718        .and_then(|value| value.strip_suffix('"'))
719    else {
720        return raw_value.trim().to_string();
721    };
722    let mut value = String::new();
723    let mut chars = stripped.chars();
724    while let Some(character) = chars.next() {
725        if character == '\\' {
726            if let Some(escaped) = chars.next() {
727                value.push(escaped);
728            }
729        } else {
730            value.push(character);
731        }
732    }
733    value
734}
735
736async fn fetch_resource_challenge(
737    client: &reqwest::Client,
738    resource_url: &Url,
739) -> Option<WwwAuthenticateChallenge> {
740    let response = client
741        .get(resource_url.clone())
742        .header(ACCEPT, "application/json")
743        .send()
744        .await
745        .ok()?;
746    let header_values = response
747        .headers()
748        .get_all(WWW_AUTHENTICATE)
749        .iter()
750        .filter_map(|value| value.to_str().ok())
751        .collect::<Vec<_>>();
752    bearer_challenge_from_headers(header_values)
753}
754
755async fn fetch_first_json<T: for<'de> Deserialize<'de>>(
756    client: &reqwest::Client,
757    candidates: &[Url],
758) -> Result<Option<(Url, T)>, McpOAuthDiscoveryError> {
759    for candidate in candidates {
760        if let Some(parsed) = fetch_json::<T>(client, candidate).await? {
761            return Ok(Some((candidate.clone(), parsed)));
762        }
763    }
764    Ok(None)
765}
766
767async fn fetch_json<T: for<'de> Deserialize<'de>>(
768    client: &reqwest::Client,
769    url: &Url,
770) -> Result<Option<T>, McpOAuthDiscoveryError> {
771    let response = match client.get(url.clone()).send().await {
772        Ok(response) => response,
773        Err(_) => return Ok(None),
774    };
775    if !response.status().is_success() {
776        return Ok(None);
777    }
778    response
779        .json::<T>()
780        .await
781        .map(Some)
782        .map_err(|error| McpOAuthDiscoveryError::Json {
783            url: url.to_string(),
784            error: error.to_string(),
785        })
786}
787
788fn dedupe_scopes<'a>(scopes: impl IntoIterator<Item = &'a str>) -> Vec<String> {
789    let mut seen = BTreeSet::new();
790    let mut ordered = Vec::new();
791    for scope in scopes {
792        let scope = scope.trim();
793        if !scope.is_empty() && seen.insert(scope.to_string()) {
794            ordered.push(scope.to_string());
795        }
796    }
797    ordered
798}
799
800fn redirect_uri_is_native(redirect_uri: &str) -> bool {
801    let Ok(url) = Url::parse(redirect_uri) else {
802        return false;
803    };
804    if url.scheme() != "http" && url.scheme() != "https" {
805        return true;
806    }
807    matches!(
808        url.host_str(),
809        Some("127.0.0.1") | Some("localhost") | Some("::1") | Some("[::1]")
810    )
811}
812
813fn normalize_path(path: &str) -> String {
814    let trimmed = path.trim();
815    if trimmed.is_empty() || trimmed == "/" {
816        "/".to_string()
817    } else if trimmed.starts_with('/') {
818        trimmed.to_string()
819    } else {
820        format!("/{trimmed}")
821    }
822}
823
824fn quote_auth_value(value: &str) -> String {
825    value.replace('\\', "\\\\").replace('"', "\\\"")
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    fn metadata(
833        issuer: &str,
834        registration_endpoint: Option<&str>,
835    ) -> OAuthAuthorizationServerMetadata {
836        OAuthAuthorizationServerMetadata {
837            issuer: issuer.to_string(),
838            authorization_endpoint: format!("{issuer}/authorize"),
839            token_endpoint: format!("{issuer}/token"),
840            registration_endpoint: registration_endpoint.map(ToString::to_string),
841            token_endpoint_auth_methods_supported: vec!["none".to_string()],
842            code_challenge_methods_supported: vec!["S256".to_string()],
843            scopes_supported: Vec::new(),
844            client_id_metadata_document_supported: false,
845            authorization_response_iss_parameter_supported: false,
846            extra: BTreeMap::new(),
847        }
848    }
849
850    #[test]
851    fn parses_bearer_challenge_resource_metadata_and_scope() {
852        let challenges = parse_www_authenticate(
853            r#"Bearer realm="mcp", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource", scope="files:read files:write""#,
854        );
855        assert_eq!(challenges.len(), 1);
856        let challenge = &challenges[0];
857        assert_eq!(
858            challenge.bearer_resource_metadata(),
859            Some("https://mcp.example/.well-known/oauth-protected-resource")
860        );
861        assert_eq!(
862            split_scope_value(challenge.bearer_scope()),
863            vec!["files:read", "files:write"]
864        );
865    }
866
867    #[test]
868    fn parses_multiple_www_authenticate_challenges() {
869        let challenge = bearer_challenge_from_headers([
870            r#"Basic realm="old""#,
871            r#"Bearer error="insufficient_scope", scope="admin", resource_metadata="https://mcp.example/meta""#,
872        ])
873        .expect("bearer challenge");
874        assert_eq!(
875            challenge.params.get("error").map(String::as_str),
876            Some("insufficient_scope")
877        );
878        assert_eq!(
879            challenge.bearer_resource_metadata(),
880            Some("https://mcp.example/meta")
881        );
882    }
883
884    #[test]
885    fn bearer_challenge_selection_prefers_resource_metadata() {
886        let challenge = bearer_challenge_from_headers([
887            r#"Bearer realm="old", Bearer resource_metadata="https://mcp.example/meta""#,
888        ])
889        .expect("bearer challenge");
890        assert_eq!(
891            challenge.bearer_resource_metadata(),
892            Some("https://mcp.example/meta")
893        );
894    }
895
896    #[test]
897    fn authorization_server_candidates_include_oidc_path_appending() {
898        let issuer = Url::parse("https://auth.example.com/tenant1").unwrap();
899        let candidates = authorization_server_metadata_candidates(&issuer);
900        let urls = candidates
901            .iter()
902            .map(|candidate| candidate.url.as_str())
903            .collect::<Vec<_>>();
904        assert_eq!(
905            urls,
906            vec![
907                "https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
908                "https://auth.example.com/.well-known/openid-configuration/tenant1",
909                "https://auth.example.com/tenant1/.well-known/openid-configuration",
910            ]
911        );
912    }
913
914    #[test]
915    fn validates_authorization_server_issuer_without_normalization() {
916        let mut metadata = metadata("https://auth.example.com", None);
917        validate_authorization_server_issuer("https://auth.example.com", &metadata).unwrap();
918        metadata.issuer = "https://auth.example.com/".to_string();
919        let err = validate_authorization_server_issuer("https://auth.example.com", &metadata)
920            .expect_err("issuer mismatch");
921        assert!(err.to_string().contains("issuer mismatch"));
922    }
923
924    #[test]
925    fn authorization_response_issuer_validation_follows_rfc9207_advertisement() {
926        let mut metadata = metadata("https://auth.example.com", None);
927        assert!(validate_authorization_response_issuer(&metadata, None).is_ok());
928        assert!(
929            validate_authorization_response_issuer(&metadata, Some("https://other.example"))
930                .is_err()
931        );
932        metadata.authorization_response_iss_parameter_supported = true;
933        assert!(validate_authorization_response_issuer(&metadata, None).is_err());
934        assert!(validate_authorization_response_issuer(
935            &metadata,
936            Some("https://auth.example.com")
937        )
938        .is_ok());
939    }
940
941    #[test]
942    fn scope_selection_prefers_challenge_scope_then_metadata_scope() {
943        assert_eq!(
944            select_oauth_scopes(Some("files:read files:write files:read"), &[]),
945            vec!["files:read", "files:write"]
946        );
947        assert_eq!(
948            select_oauth_scopes(None, &["basic".to_string(), "profile".to_string()]),
949            vec!["basic", "profile"]
950        );
951    }
952
953    #[test]
954    fn client_registration_mode_selection_is_explicit() {
955        let mut meta = metadata(
956            "https://auth.example.com",
957            Some("https://auth.example.com/reg"),
958        );
959        assert_eq!(
960            select_client_registration_mode(&meta, OAuthClientRegistrationOptions::default()),
961            OAuthClientRegistrationMode::DynamicClientRegistration
962        );
963        assert_eq!(
964            select_client_registration_mode(
965                &meta,
966                OAuthClientRegistrationOptions {
967                    client_id: Some("static-client"),
968                    ..OAuthClientRegistrationOptions::default()
969                },
970            ),
971            OAuthClientRegistrationMode::PreRegistered
972        );
973        meta.client_id_metadata_document_supported = true;
974        assert_eq!(
975            select_client_registration_mode(
976                &meta,
977                OAuthClientRegistrationOptions {
978                    client_id: Some("https://client.example/oauth/client.json"),
979                    ..OAuthClientRegistrationOptions::default()
980                },
981            ),
982            OAuthClientRegistrationMode::ClientIdMetadataDocument
983        );
984    }
985
986    #[test]
987    fn dynamic_registration_body_marks_loopback_clients_native() {
988        let body = dynamic_client_registration_body(
989            "Harn CLI",
990            ["http://127.0.0.1:49152/oauth/callback"],
991            Some("mcp.read"),
992        );
993        assert_eq!(body["application_type"], "native");
994        assert_eq!(body["token_endpoint_auth_method"], "none");
995        assert_eq!(body["grant_types"][1], "refresh_token");
996        assert_eq!(body["scope"], "mcp.read");
997    }
998
999    #[test]
1000    fn token_refresh_binding_rejects_cross_issuer_reuse() {
1001        assert!(
1002            validate_issuer_binding("https://issuer-a.example", "https://issuer-a.example").is_ok()
1003        );
1004        assert!(
1005            validate_issuer_binding("https://issuer-a.example", "https://issuer-b.example")
1006                .is_err()
1007        );
1008    }
1009}