Skip to main content

yapi_core/mcp/
oauth.rs

1//! OAuth for MCP servers: protected resource and authorization server
2//! discovery, dynamic client registration, the authorization code flow with
3//! Proof Key for Code Exchange (PKCE), and refresh. Port of `oauth/` in
4//! pi-mcp `v1.0.0`, which adapts the MCP TypeScript SDK, limited to what pi's
5//! coding agent uses.
6//!
7//! Metadata, client registrations and tokens are JSON objects kept as read,
8//! so the state pi and yapi share in `mcp-auth.json` keeps its fields and
9//! their order.
10
11use std::sync::LazyLock;
12use std::time::Duration;
13
14use base64::Engine as _;
15use regex_lite::Regex;
16use reqwest::Url;
17use serde_json::{Map, Value};
18
19use super::client::LATEST_PROTOCOL_VERSION;
20use super::sign_in::ServerStore;
21
22/// A JSON object as pi spreads and stores it.
23pub type Object = Map<String, Value>;
24
25/// Why an OAuth step failed. Messages match pi-mcp's.
26#[derive(Clone, Debug, PartialEq, thiserror::Error)]
27pub enum OAuthError {
28    /// pi's `OAuthError`: an OAuth error response.
29    #[error("{message}")]
30    Response {
31        /// The `error` code.
32        code: String,
33        /// `error_description`, else the code.
34        message: String,
35    },
36    /// A token request would send credentials over plain HTTP.
37    #[error("Refusing to send OAuth credentials to non-HTTPS endpoint {0}")]
38    InsecureEndpoint(String),
39    /// A request got no response, as `fetch` failing.
40    #[error("{0}")]
41    Network(String),
42    /// Anything else, with pi's message.
43    #[error("{0}")]
44    Other(String),
45}
46
47fn other(message: impl Into<String>) -> OAuthError {
48    OAuthError::Other(message.into())
49}
50
51/// pi's `OAuthIssuerMismatchError`.
52fn issuer_mismatch(expected: &str, received: Option<&str>) -> OAuthError {
53    let quote = |text: &str| yapi_types::json::stringify(&Value::from(text));
54    other(format!(
55        "OAuth issuer mismatch: expected {}, received {}",
56        quote(expected),
57        received.map_or_else(|| "none".to_owned(), quote)
58    ))
59}
60
61// ---------------------------------------------------------------------------
62// Parsing (types.ts)
63// ---------------------------------------------------------------------------
64
65fn object<'a>(value: &'a Value, name: &str) -> Result<&'a Object, OAuthError> {
66    value
67        .as_object()
68        .ok_or_else(|| other(format!("Invalid {name}")))
69}
70
71/// `null` and `""` count as absent: servers send them for fields they have
72/// no value for.
73fn absent(value: Option<&Value>) -> bool {
74    matches!(value, None | Some(Value::Null)) || value.and_then(Value::as_str) == Some("")
75}
76
77fn required_string(value: Option<&Value>, name: &str) -> Result<String, OAuthError> {
78    value
79        .and_then(Value::as_str)
80        .filter(|text| !text.is_empty())
81        .map(str::to_owned)
82        .ok_or_else(|| other(format!("Invalid {name}")))
83}
84
85fn optional_string(value: Option<&Value>, name: &str) -> Result<Option<Value>, OAuthError> {
86    if absent(value) {
87        return Ok(None);
88    }
89    required_string(value, name).map(|text| Some(Value::String(text)))
90}
91
92fn optional_strings(value: Option<&Value>, name: &str) -> Result<Option<Vec<Value>>, OAuthError> {
93    match value {
94        None | Some(Value::Null) => Ok(None),
95        Some(Value::Array(items)) if items.iter().all(Value::is_string) => Ok(Some(items.clone())),
96        Some(_) => Err(other(format!("Invalid {name}"))),
97    }
98}
99
100fn safe_url(value: Option<&Value>, name: &str) -> Result<String, OAuthError> {
101    let text = required_string(value, name)?;
102    match Url::parse(&text) {
103        Ok(url) if !["javascript", "data", "vbscript"].contains(&url.scheme()) => Ok(text),
104        _ => Err(other(format!("Invalid {name}"))),
105    }
106}
107
108fn optional_url(value: Option<&Value>, name: &str) -> Result<Option<Value>, OAuthError> {
109    if absent(value) {
110        return Ok(None);
111    }
112    safe_url(value, name).map(|text| Some(Value::String(text)))
113}
114
115/// `compact({...input, key: value, ...})`: each field replaces its namesake in
116/// place or is appended, and `None` removes it.
117fn spread<'a>(
118    input: &Object,
119    fields: impl IntoIterator<Item = (&'a str, Option<Value>)>,
120) -> Object {
121    let mut out = input.clone();
122    for (key, value) in fields {
123        match value {
124            Some(value) => {
125                out.insert(key.to_owned(), value);
126            }
127            None => {
128                out.shift_remove(key);
129            }
130        }
131    }
132    out
133}
134
135fn string_field(value: Option<&Value>, name: &str) -> Result<Option<Value>, OAuthError> {
136    required_string(value, name).map(|text| Some(Value::String(text)))
137}
138
139fn url_field(value: Option<&Value>, name: &str) -> Result<Option<Value>, OAuthError> {
140    safe_url(value, name).map(|text| Some(Value::String(text)))
141}
142
143fn strings_field(value: Option<&Value>, name: &str) -> Result<Option<Value>, OAuthError> {
144    optional_strings(value, name).map(|items| items.map(Value::Array))
145}
146
147fn boolean(value: Option<&Value>) -> Option<Value> {
148    value.filter(|value| value.is_boolean()).cloned()
149}
150
151fn parse_protected_resource_metadata(value: &Value) -> Result<Object, OAuthError> {
152    let input = object(value, "OAuth protected resource metadata")?;
153    let resource = url_field(
154        input.get("resource"),
155        "OAuth protected resource metadata resource",
156    )?;
157    let servers = optional_strings(input.get("authorization_servers"), "authorization_servers")?
158        .map(|servers| {
159            servers
160                .iter()
161                .map(|url| safe_url(Some(url), "authorization server URL").map(Value::String))
162                .collect::<Result<Vec<_>, _>>()
163        })
164        .transpose()?;
165    Ok(spread(
166        input,
167        [
168            ("resource", resource),
169            ("authorization_servers", servers.map(Value::Array)),
170            (
171                "scopes_supported",
172                strings_field(input.get("scopes_supported"), "scopes_supported")?,
173            ),
174        ],
175    ))
176}
177
178fn parse_authorization_server_metadata(value: &Value) -> Result<Object, OAuthError> {
179    let input = object(value, "authorization server metadata")?;
180    let get = |key: &str| input.get(key);
181    let list = |key: &str| strings_field(get(key), key);
182    let response_types = list("response_types_supported")?
183        .ok_or_else(|| other("Invalid response_types_supported"))?;
184    let fields = [
185        (
186            "issuer",
187            url_field(get("issuer"), "authorization server issuer")?,
188        ),
189        (
190            "authorization_endpoint",
191            url_field(get("authorization_endpoint"), "authorization endpoint")?,
192        ),
193        (
194            "token_endpoint",
195            url_field(get("token_endpoint"), "token endpoint")?,
196        ),
197        (
198            "registration_endpoint",
199            optional_url(get("registration_endpoint"), "registration endpoint")?,
200        ),
201        ("scopes_supported", list("scopes_supported")?),
202        ("response_types_supported", Some(response_types)),
203        ("grant_types_supported", list("grant_types_supported")?),
204        (
205            "token_endpoint_auth_methods_supported",
206            list("token_endpoint_auth_methods_supported")?,
207        ),
208        (
209            "code_challenge_methods_supported",
210            list("code_challenge_methods_supported")?,
211        ),
212        (
213            "client_id_metadata_document_supported",
214            boolean(get("client_id_metadata_document_supported")),
215        ),
216        (
217            "authorization_response_iss_parameter_supported",
218            boolean(get("authorization_response_iss_parameter_supported")),
219        ),
220    ];
221    Ok(spread(input, fields))
222}
223
224/// `Number(value)` for `expires_in`, or `None` when it is not a number.
225fn js_number(value: &Value) -> Option<f64> {
226    match value {
227        Value::Number(number) => number.as_f64(),
228        Value::String(text) => Some(yapi_types::json::js_number(text)),
229        Value::Bool(flag) => Some(f64::from(u8::from(*flag))),
230        _ => None,
231    }
232}
233
234fn parse_tokens(value: &Value) -> Result<Object, OAuthError> {
235    let input = object(value, "OAuth token response")?;
236    // `Number(null)` is 0, which would mark the token as expired at once.
237    let expires_in = match input.get("expires_in") {
238        value if absent(value) => None,
239        value => match value.and_then(js_number) {
240            Some(seconds) if seconds.is_finite() => Some(yapi_types::json::number(seconds)),
241            _ => return Err(other("Invalid expires_in")),
242        },
243    };
244    let get = |key: &str| optional_string(input.get(key), key);
245    let fields = [
246        (
247            "access_token",
248            string_field(input.get("access_token"), "access_token")?,
249        ),
250        (
251            "token_type",
252            string_field(input.get("token_type"), "token_type")?,
253        ),
254        ("expires_in", expires_in),
255        ("scope", get("scope")?),
256        ("refresh_token", get("refresh_token")?),
257        ("id_token", get("id_token")?),
258    ];
259    Ok(spread(&Object::new(), fields))
260}
261
262fn parse_client_information(value: &Value) -> Result<Object, OAuthError> {
263    let input = object(value, "OAuth client registration response")?;
264    let number = |key: &str| input.get(key).filter(|value| value.is_number()).cloned();
265    let fields = [
266        (
267            "client_id",
268            string_field(input.get("client_id"), "client_id")?,
269        ),
270        (
271            "client_secret",
272            optional_string(input.get("client_secret"), "client_secret")?,
273        ),
274        ("client_id_issued_at", number("client_id_issued_at")),
275        (
276            "client_secret_expires_at",
277            number("client_secret_expires_at"),
278        ),
279        (
280            "redirect_uris",
281            Some(
282                strings_field(input.get("redirect_uris"), "redirect_uris")?
283                    .unwrap_or_else(|| Value::Array(Vec::new())),
284            ),
285        ),
286    ];
287    Ok(spread(input, fields))
288}
289
290/// The string field `key` of an object.
291pub(super) fn text<'a>(object: &'a Object, key: &str) -> Option<&'a str> {
292    object.get(key).and_then(Value::as_str)
293}
294
295/// The non-empty string field `key`, as JavaScript's truthiness reads it.
296fn truthy<'a>(object: &'a Object, key: &str) -> Option<&'a str> {
297    text(object, key).filter(|text| !text.is_empty())
298}
299
300fn list<'a>(object: &'a Object, key: &str) -> Option<Vec<&'a str>> {
301    object
302        .get(key)
303        .and_then(Value::as_array)
304        .map(|items| items.iter().filter_map(Value::as_str).collect())
305}
306
307// ---------------------------------------------------------------------------
308// Discovery (discovery.ts)
309// ---------------------------------------------------------------------------
310
311/// pi's `OAuthChallenge`: a `WWW-Authenticate` challenge.
312#[derive(Clone, Debug, Default, PartialEq, Eq)]
313pub struct Challenge {
314    /// `resource_metadata`, when it is a URL.
315    pub resource_metadata_url: Option<String>,
316    /// `scope`.
317    pub scope: Option<String>,
318    /// `error`, such as `insufficient_scope`.
319    pub error: Option<String>,
320}
321
322fn field(header: &str, name: &str) -> Option<String> {
323    let pattern = format!(r#"(?i)(?:^|[,\s]){name}=(?:"([^"]*)"|([^\s,]+))"#);
324    let captures = Regex::new(&pattern).ok()?.captures(header)?;
325    // An empty value carries no information, so it counts as absent.
326    captures
327        .get(1)
328        .or_else(|| captures.get(2))
329        .map(|value| value.as_str().to_owned())
330        .filter(|value| !value.is_empty())
331}
332
333/// pi's `parseWwwAuthenticate`: the fields of a Bearer or DPoP (Demonstrating
334/// Proof of Possession) challenge.
335pub fn parse_www_authenticate(header: Option<&str>) -> Challenge {
336    let Some(header) = header.filter(|header| !header.is_empty()) else {
337        return Challenge::default();
338    };
339    let scheme = header
340        .split_whitespace()
341        .next()
342        .unwrap_or_default()
343        .to_ascii_lowercase();
344    if scheme != "bearer" && scheme != "dpop" {
345        return Challenge::default();
346    }
347    Challenge {
348        resource_metadata_url: field(header, "resource_metadata")
349            .and_then(|url| Url::parse(&url).ok())
350            .map(String::from),
351        scope: field(header, "scope"),
352        error: field(header, "error"),
353    }
354}
355
356/// Whether a 403 response asks for more scope (step-up authorization).
357pub fn is_insufficient_scope(header: Option<&str>) -> bool {
358    static PATTERN: LazyLock<Option<Regex>> =
359        LazyLock::new(|| Regex::new(r#"(?i)(?:^|[\s,])error="?insufficient_scope"?"#).ok());
360    header.is_some_and(|header| {
361        PATTERN
362            .as_ref()
363            .is_some_and(|pattern| pattern.is_match(header))
364    })
365}
366
367/// How requests of one flow are sent.
368#[derive(Clone, Copy, Debug, Default)]
369pub struct Fetch {
370    /// Bounds each request.
371    pub timeout: Option<Duration>,
372}
373
374impl Fetch {
375    async fn send(
376        &self,
377        request: reqwest::RequestBuilder,
378    ) -> Result<reqwest::Response, OAuthError> {
379        let request = match self.timeout {
380            Some(timeout) => request.timeout(timeout),
381            None => request,
382        };
383        request.send().await.map_err(|error| {
384            if error.is_timeout() {
385                other("The operation was aborted due to timeout")
386            } else {
387                // Node's `fetch` reports every network failure this way.
388                OAuthError::Network("fetch failed".into())
389            }
390        })
391    }
392
393    async fn metadata(&self, url: &Url) -> Result<reqwest::Response, OAuthError> {
394        self.send(
395            yapi_ai::http::client()
396                .get(url.clone())
397                .header("Accept", "application/json")
398                .header("MCP-Protocol-Version", LATEST_PROTOCOL_VERSION),
399        )
400        .await
401    }
402}
403
404async fn json(response: reqwest::Response) -> Result<Value, OAuthError> {
405    let bytes = response
406        .bytes()
407        .await
408        .map_err(|_| OAuthError::Network("fetch failed".into()))?;
409    serde_json::from_slice(&bytes).map_err(|error| other(error.to_string()))
410}
411
412/// 4xx and 502 mean "not here", so discovery tries the next candidate.
413fn is_discovery_miss(status: u16) -> bool {
414    (400..500).contains(&status) || status == 502
415}
416
417/// The path suffix for `/.well-known/<kind><path>`, empty for the root path.
418fn path_suffix(path: &str) -> &str {
419    path.strip_suffix('/').unwrap_or(path)
420}
421
422fn parse_url(text: &str) -> Result<Url, OAuthError> {
423    Url::parse(text).map_err(|_| other(format!("Invalid URL: {text}")))
424}
425
426fn join(base: &Url, path: &str) -> Result<Url, OAuthError> {
427    base.join(path)
428        .map_err(|_| other(format!("Invalid URL: {path}")))
429}
430
431async fn discover_protected_resource_metadata(
432    server_url: &str,
433    resource_metadata_url: Option<&str>,
434    fetch: Fetch,
435) -> Result<Object, OAuthError> {
436    let server = parse_url(server_url)?;
437    let url = match resource_metadata_url {
438        Some(url) => parse_url(url)?,
439        None => join(
440            &server,
441            &format!(
442                "/.well-known/oauth-protected-resource{}",
443                path_suffix(server.path())
444            ),
445        )?,
446    };
447    let mut response = fetch.metadata(&url).await?;
448    if resource_metadata_url.is_none()
449        && server.path() != "/"
450        && is_discovery_miss(response.status().as_u16())
451    {
452        response = fetch
453            .metadata(&join(&server, "/.well-known/oauth-protected-resource")?)
454            .await?;
455    }
456    if !response.status().is_success() {
457        return Err(other(format!(
458            "HTTP {} loading OAuth protected resource metadata",
459            response.status().as_u16()
460        )));
461    }
462    parse_protected_resource_metadata(&json(response).await?)
463}
464
465/// pi's `buildAuthorizationServerDiscoveryUrls`: RFC 8414, then OpenID
466/// Connect discovery with the path inserted and appended.
467fn discovery_urls(authorization_server_url: &str) -> Result<Vec<Url>, OAuthError> {
468    let issuer = parse_url(authorization_server_url)?;
469    let path = path_suffix(issuer.path());
470    let mut urls = vec![
471        join(
472            &issuer,
473            &format!("/.well-known/oauth-authorization-server{path}"),
474        )?,
475        join(&issuer, &format!("/.well-known/openid-configuration{path}"))?,
476    ];
477    if !path.is_empty() {
478        urls.push(join(
479            &issuer,
480            &format!("{path}/.well-known/openid-configuration"),
481        )?);
482    }
483    Ok(urls)
484}
485
486async fn discover_authorization_server_metadata(
487    authorization_server_url: &str,
488    fetch: Fetch,
489) -> Result<Option<Object>, OAuthError> {
490    for url in discovery_urls(authorization_server_url)? {
491        let response = fetch.metadata(&url).await?;
492        let status = response.status().as_u16();
493        if !response.status().is_success() {
494            if is_discovery_miss(status) {
495                continue;
496            }
497            return Err(other(format!(
498                "HTTP {status} loading authorization server metadata from {url}"
499            )));
500        }
501        let metadata = parse_authorization_server_metadata(&json(response).await?)?;
502        let issuer = text(&metadata, "issuer").unwrap_or_default();
503        // URL parsing adds a trailing slash to bare origins, so compare without one.
504        let trim = |value: &str| value.strip_suffix('/').unwrap_or(value).to_owned();
505        if trim(issuer) != trim(authorization_server_url) {
506            return Err(issuer_mismatch(authorization_server_url, Some(issuer)));
507        }
508        return Ok(Some(metadata));
509    }
510    Ok(None)
511}
512
513/// pi's `OAuthServerInfo` as the discovery state stores it:
514/// `authorizationServerUrl`, `authorizationServerMetadata`,
515/// `resourceMetadata`.
516fn server_info(url: String, metadata: Option<Object>, resource: Option<Object>) -> Object {
517    spread(
518        &Object::new(),
519        [
520            ("authorizationServerUrl", Some(Value::String(url))),
521            ("authorizationServerMetadata", metadata.map(Value::Object)),
522            ("resourceMetadata", resource.map(Value::Object)),
523        ],
524    )
525}
526
527async fn discover_server_info(
528    server_url: &str,
529    resource_metadata_url: Option<&str>,
530    metadata_url: Option<&Url>,
531    fetch: Fetch,
532) -> Result<Object, OAuthError> {
533    let resource = match discover_protected_resource_metadata(
534        server_url,
535        resource_metadata_url,
536        fetch,
537    )
538    .await
539    {
540        Ok(resource) => Some(resource),
541        Err(OAuthError::Network(message)) => return Err(OAuthError::Network(message)),
542        Err(_) => None,
543    };
544    if let Some(url) = metadata_url {
545        let response = fetch.metadata(url).await?;
546        if !response.status().is_success() {
547            return Err(other(format!(
548                "HTTP {} loading authorization server metadata from {url}",
549                response.status().as_u16()
550            )));
551        }
552        let metadata = parse_authorization_server_metadata(&json(response).await?)?;
553        let issuer = text(&metadata, "issuer").unwrap_or_default().to_owned();
554        return Ok(server_info(issuer, Some(metadata), resource));
555    }
556    let authorization_server = match resource
557        .as_ref()
558        .and_then(|resource| list(resource, "authorization_servers"))
559        .and_then(|servers| servers.first().map(|server| (*server).to_owned()))
560    {
561        Some(server) => server,
562        None => join(&parse_url(server_url)?, "/")?.to_string(),
563    };
564    let metadata = discover_authorization_server_metadata(&authorization_server, fetch).await?;
565    Ok(server_info(authorization_server, metadata, resource))
566}
567
568/// pi's `selectResource`: the protected resource for the server, which must
569/// contain its URL.
570fn select_resource(
571    server_url: &str,
572    metadata: Option<&Object>,
573) -> Result<Option<String>, OAuthError> {
574    let Some(metadata) = metadata else {
575        return Ok(None);
576    };
577    let resource = text(metadata, "resource").unwrap_or_default();
578    let mut requested = parse_url(server_url)?;
579    requested.set_fragment(None);
580    let configured = parse_url(resource)?;
581    let with_slash = |path: &str| {
582        if path.ends_with('/') {
583            path.to_owned()
584        } else {
585            format!("{path}/")
586        }
587    };
588    if requested.origin() != configured.origin()
589        || !with_slash(requested.path()).starts_with(&with_slash(configured.path()))
590    {
591        return Err(other(format!(
592            "Protected resource {resource} does not match MCP server {requested}"
593        )));
594    }
595    Ok(Some(resource.to_owned()))
596}
597
598// ---------------------------------------------------------------------------
599// Flow (flow.ts)
600// ---------------------------------------------------------------------------
601
602fn is_loopback(url: &Url) -> bool {
603    matches!(
604        url.host_str(),
605        Some("localhost" | "127.0.0.1" | "[::1]" | "::1")
606    )
607}
608
609/// The URL, unless it would send credentials over plain HTTP to another host.
610pub fn secure_endpoint(value: &str) -> Result<Url, OAuthError> {
611    let url = parse_url(value)?;
612    if url.scheme() != "https" && !is_loopback(&url) {
613        return Err(OAuthError::InsecureEndpoint(url.to_string()));
614    }
615    Ok(url)
616}
617
618#[derive(Clone, Copy, PartialEq, Eq)]
619enum ClientAuth {
620    Basic,
621    Post,
622    None,
623}
624
625fn select_client_auth(information: &Object, supported: &[&str]) -> ClientAuth {
626    let secret = truthy(information, "client_secret").is_some();
627    let parse = |method: &str| match method {
628        "client_secret_basic" => Some(ClientAuth::Basic),
629        "client_secret_post" => Some(ClientAuth::Post),
630        "none" => Some(ClientAuth::None),
631        _ => None,
632    };
633    if let Some(hinted) = truthy(information, "token_endpoint_auth_method")
634        && let Some(method) = parse(hinted)
635        && (supported.is_empty() || supported.contains(&hinted))
636    {
637        return method;
638    }
639    if supported.is_empty() {
640        return if secret {
641            ClientAuth::Basic
642        } else {
643            ClientAuth::None
644        };
645    }
646    if secret && supported.contains(&"client_secret_basic") {
647        return ClientAuth::Basic;
648    }
649    if secret && supported.contains(&"client_secret_post") {
650        return ClientAuth::Post;
651    }
652    if supported.contains(&"none") {
653        return ClientAuth::None;
654    }
655    if secret {
656        ClientAuth::Post
657    } else {
658        ClientAuth::None
659    }
660}
661
662/// `URLSearchParams.set`: replaces every pair named `key` with one.
663fn set(params: &mut Vec<(String, String)>, key: &str, value: &str) {
664    params.retain(|(name, _)| name != key);
665    params.push((key.to_owned(), value.to_owned()));
666}
667
668/// Where a token request goes and how the client authenticates.
669struct TokenRequest<'a> {
670    authorization_server_url: &'a str,
671    metadata: Option<&'a Object>,
672    client: &'a Object,
673    resource: Option<&'a str>,
674    fetch: Fetch,
675}
676
677impl TokenRequest<'_> {
678    async fn send(&self, mut params: Vec<(String, String)>) -> Result<Object, OAuthError> {
679        let endpoint = match self
680            .metadata
681            .and_then(|metadata| text(metadata, "token_endpoint"))
682        {
683            Some(endpoint) => endpoint.to_owned(),
684            None => join(&parse_url(self.authorization_server_url)?, "/token")?.to_string(),
685        };
686        let url = secure_endpoint(&endpoint)?;
687        if let Some(resource) = self.resource {
688            set(&mut params, "resource", resource);
689        }
690        let supported = self
691            .metadata
692            .and_then(|metadata| list(metadata, "token_endpoint_auth_methods_supported"))
693            .unwrap_or_default();
694        let client_id = text(self.client, "client_id").unwrap_or_default();
695        let secret = truthy(self.client, "client_secret");
696        let mut basic = None;
697        match select_client_auth(self.client, &supported) {
698            ClientAuth::Basic => {
699                let secret =
700                    secret.ok_or_else(|| other("client_secret_basic requires a client secret"))?;
701                let credentials = base64::engine::general_purpose::STANDARD
702                    .encode(format!("{client_id}:{secret}"));
703                basic = Some(format!("Basic {credentials}"));
704            }
705            method => {
706                set(&mut params, "client_id", client_id);
707                if method == ClientAuth::Post
708                    && let Some(secret) = secret
709                {
710                    set(&mut params, "client_secret", secret);
711                }
712            }
713        }
714        let fields: Vec<(&str, &str)> = params
715            .iter()
716            .map(|(name, value)| (name.as_str(), value.as_str()))
717            .collect();
718        let mut request = yapi_ai::auth::post_form(url, &fields);
719        if let Some(basic) = basic {
720            request = request.header("Authorization", basic);
721        }
722        let response = self.fetch.send(request).await?;
723        let status = response.status();
724        let body = response
725            .text()
726            .await
727            .map_err(|_| OAuthError::Network("fetch failed".into()))?;
728        let value: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
729        // Servers may report OAuth errors with any status.
730        if let Some(code) = value.get("error").and_then(Value::as_str) {
731            let message = value
732                .get("error_description")
733                .and_then(Value::as_str)
734                .unwrap_or(code);
735            return Err(OAuthError::Response {
736                code: code.to_owned(),
737                message: if message.is_empty() { code } else { message }.to_owned(),
738            });
739        }
740        if !status.is_success() {
741            return Err(OAuthError::Response {
742                code: "server_error".into(),
743                message: format!("HTTP {}: {body}", status.as_u16()),
744            });
745        }
746        parse_tokens(&value)
747    }
748}
749
750async fn register_client(
751    authorization_server_url: &str,
752    metadata: Option<&Object>,
753    client_metadata: &Object,
754    scope: Option<&str>,
755    fetch: Fetch,
756) -> Result<Object, OAuthError> {
757    let endpoint = metadata.and_then(|metadata| text(metadata, "registration_endpoint"));
758    if metadata.is_some() && endpoint.is_none() {
759        return Err(other(
760            "Authorization server does not support dynamic client registration",
761        ));
762    }
763    let url = match endpoint {
764        Some(endpoint) => parse_url(endpoint)?,
765        None => join(&parse_url(authorization_server_url)?, "/register")?,
766    };
767    let mut body = client_metadata.clone();
768    if let Some(scope) = scope {
769        body.insert("scope".into(), Value::String(scope.to_owned()));
770    }
771    let response = fetch
772        .send(
773            yapi_ai::http::client()
774                .post(url)
775                .header("Accept", "application/json")
776                .header("content-type", "application/json")
777                .body(yapi_types::json::stringify(&body)),
778        )
779        .await?;
780    let status = response.status();
781    if !status.is_success() {
782        let body = response.text().await.unwrap_or_default();
783        return Err(other(format!(
784            "OAuth dynamic client registration failed with status {}: {body}",
785            status.as_u16()
786        )));
787    }
788    parse_client_information(&json(response).await?)
789}
790
791/// A response without `scope` grants the requested scope (RFC 6749).
792fn with_scope(mut tokens: Object, scope: Option<&str>) -> Object {
793    if !tokens.contains_key("scope")
794        && let Some(scope) = scope.filter(|scope| !scope.is_empty())
795    {
796        tokens.insert("scope".into(), Value::String(scope.to_owned()));
797    }
798    tokens
799}
800
801/// pi's `OAuthFlowOptions`: what one run of the flow needs.
802#[derive(Clone, Debug, Default)]
803pub struct FlowOptions {
804    /// The MCP server.
805    pub server_url: String,
806    /// The code an authorization response delivered.
807    pub authorization_code: Option<String>,
808    /// The `iss` parameter of that response (RFC 9207).
809    pub iss: Option<String>,
810    /// Scopes to request.
811    pub scope: Option<String>,
812    /// `resource_metadata` of the server's challenge.
813    pub resource_metadata_url: Option<String>,
814    /// Authorization server metadata to use instead of discovery.
815    pub authorization_server_metadata_url: Option<String>,
816    /// Go straight to authorization instead of refreshing stored tokens.
817    pub skip_refresh: bool,
818    /// How requests are sent.
819    pub fetch: Fetch,
820}
821
822/// The outcome of [`authorize`].
823#[derive(Clone, Debug, PartialEq, Eq)]
824pub enum FlowResult {
825    /// Tokens are stored.
826    Authorized,
827    /// The user has to open this authorization URL.
828    Redirect(String),
829}
830
831/// What [`Provider::invalidate`] forgets.
832#[derive(Clone, Copy, Debug, PartialEq, Eq)]
833enum Invalidate {
834    All,
835    Tokens,
836}
837
838/// pi's `McpOAuthProvider`: the OAuth state of one exact server URL in a
839/// [`ServerStore`].
840pub struct Provider<'a> {
841    server_url: String,
842    redirect_url: String,
843    client_metadata: Object,
844    configured_client: Option<Object>,
845    store: &'a ServerStore,
846}
847
848impl<'a> Provider<'a> {
849    /// A provider for `server_url` that redirects to `redirect_url`.
850    /// `client_id` and `client_secret` name a pre-registered client.
851    pub fn new(
852        server_url: &str,
853        redirect_url: &str,
854        client_name: &str,
855        client_id: Option<&str>,
856        client_secret: Option<&str>,
857        store: &'a ServerStore,
858    ) -> Result<Provider<'a>, OAuthError> {
859        let mut client_metadata = Object::new();
860        client_metadata.insert("client_name".into(), Value::from(client_name));
861        client_metadata.insert("redirect_uris".into(), Value::from(vec![redirect_url]));
862        client_metadata.insert(
863            "grant_types".into(),
864            Value::from(vec!["authorization_code", "refresh_token"]),
865        );
866        client_metadata.insert("response_types".into(), Value::from(vec!["code"]));
867        client_metadata.insert(
868            "token_endpoint_auth_method".into(),
869            Value::from(if client_secret.is_some_and(|secret| !secret.is_empty()) {
870                "client_secret_post"
871            } else {
872                "none"
873            }),
874        );
875        let configured_client = client_id.filter(|id| !id.is_empty()).map(|id| {
876            let mut client = Object::new();
877            client.insert("client_id".into(), Value::from(id));
878            if let Some(secret) = client_secret.filter(|secret| !secret.is_empty()) {
879                client.insert("client_secret".into(), Value::from(secret));
880            }
881            client
882        });
883        Ok(Provider {
884            server_url: parse_url(server_url)?.to_string(),
885            redirect_url: redirect_url.to_owned(),
886            client_metadata,
887            configured_client,
888            store,
889        })
890    }
891
892    /// Stored state for another server URL is ignored, so credentials never
893    /// leak across servers.
894    async fn load(&self) -> Result<Object, OAuthError> {
895        let state = self
896            .store
897            .load()
898            .await
899            .map_err(|error| other(error.to_string()))?;
900        Ok(match state {
901            Some(state) if text(&state, "serverUrl") == Some(self.server_url.as_str()) => state,
902            _ => {
903                let mut state = Object::new();
904                state.insert("serverUrl".into(), Value::String(self.server_url.clone()));
905                state
906            }
907        })
908    }
909
910    async fn update(&self, change: impl FnOnce(&mut Object)) -> Result<(), OAuthError> {
911        let mut state = self.load().await?;
912        change(&mut state);
913        self.store
914            .save(state)
915            .await
916            .map_err(|error| other(error.to_string()))
917    }
918
919    /// The `state` parameter, created once per sign-in.
920    pub async fn state(&self) -> Result<String, OAuthError> {
921        if let Some(state) = truthy(&self.load().await?, "oauthState") {
922            return Ok(state.to_owned());
923        }
924        let state = yapi_types::time::try_random_bytes::<32>()
925            .map(|bytes| yapi_types::time::hex(&bytes))
926            .map_err(|err| other(yapi_ai::auth::AuthError::random(err).to_string()))?;
927        let value = state.clone();
928        self.update(|stored| {
929            stored.insert("oauthState".into(), Value::String(value));
930        })
931        .await?;
932        Ok(state)
933    }
934
935    /// The stored object field `key`.
936    async fn stored(&self, key: &str) -> Result<Option<Object>, OAuthError> {
937        Ok(self
938            .load()
939            .await?
940            .get(key)
941            .and_then(Value::as_object)
942            .cloned())
943    }
944
945    async fn client_information(&self) -> Result<Option<Object>, OAuthError> {
946        match &self.configured_client {
947            Some(client) => Ok(Some(client.clone())),
948            None => self.stored("clientInformation").await,
949        }
950    }
951
952    async fn save_client_information(&self, information: Object) -> Result<(), OAuthError> {
953        if self.configured_client.is_some() {
954            return Ok(());
955        }
956        self.update(|state| {
957            state.insert("clientInformation".into(), Value::Object(information));
958        })
959        .await
960    }
961
962    async fn save_tokens(&self, tokens: Object) -> Result<(), OAuthError> {
963        let expires_at = tokens
964            .get("expires_in")
965            .and_then(Value::as_f64)
966            .map(|seconds| {
967                yapi_types::json::number(yapi_types::time::now_ms() as f64 + seconds * 1000.0)
968            });
969        self.update(|state| {
970            state.insert("tokens".into(), Value::Object(tokens));
971            *state = spread(state, [("tokensExpireAt", expires_at)]);
972        })
973        .await
974    }
975
976    async fn code_verifier(&self) -> Result<String, OAuthError> {
977        truthy(&self.load().await?, "codeVerifier")
978            .map(str::to_owned)
979            .ok_or_else(|| other("No OAuth PKCE code verifier is stored"))
980    }
981
982    async fn invalidate(&self, kind: Invalidate) -> Result<(), OAuthError> {
983        self.update(|state| {
984            let keys: &[&str] = match kind {
985                Invalidate::All => &[
986                    "clientInformation",
987                    "tokens",
988                    "tokensExpireAt",
989                    "codeVerifier",
990                    "discovery",
991                    "oauthState",
992                ],
993                Invalidate::Tokens => &["tokens", "tokensExpireAt"],
994            };
995            for key in keys {
996                state.shift_remove(*key);
997            }
998        })
999        .await
1000    }
1001}
1002
1003fn start_authorization(
1004    authorization_server_url: &str,
1005    metadata: Option<&Object>,
1006    client: &Object,
1007    redirect_url: &str,
1008    scope: Option<&str>,
1009    state: &str,
1010    resource: Option<&str>,
1011) -> Result<(Url, String), OAuthError> {
1012    if let Some(metadata) = metadata {
1013        if !list(metadata, "response_types_supported")
1014            .unwrap_or_default()
1015            .contains(&"code")
1016        {
1017            return Err(other(
1018                "Authorization server does not support authorization codes",
1019            ));
1020        }
1021        if list(metadata, "code_challenge_methods_supported")
1022            .is_some_and(|methods| !methods.contains(&"S256"))
1023        {
1024            return Err(other("Authorization server does not support PKCE S256"));
1025        }
1026    }
1027    let mut url = match metadata.and_then(|metadata| text(metadata, "authorization_endpoint")) {
1028        Some(endpoint) => parse_url(endpoint)?,
1029        None => join(&parse_url(authorization_server_url)?, "/authorize")?,
1030    };
1031    // The page opens without a click, and another scheme could launch an app (pi opens any).
1032    if !matches!(url.scheme(), "http" | "https") {
1033        return Err(other("Invalid authorization endpoint"));
1034    }
1035    let pkce = yapi_ai::auth::pkce::generate().map_err(|err| other(err.to_string()))?;
1036    let mut params: Vec<(String, String)> = url.query_pairs().into_owned().collect();
1037    set(&mut params, "response_type", "code");
1038    set(
1039        &mut params,
1040        "client_id",
1041        text(client, "client_id").unwrap_or_default(),
1042    );
1043    set(&mut params, "code_challenge", &pkce.challenge);
1044    set(&mut params, "code_challenge_method", "S256");
1045    set(&mut params, "redirect_uri", redirect_url);
1046    if !state.is_empty() {
1047        set(&mut params, "state", state);
1048    }
1049    if let Some(scope) = scope {
1050        set(&mut params, "scope", scope);
1051        if scope
1052            .split_whitespace()
1053            .any(|word| word == "offline_access")
1054        {
1055            set(&mut params, "prompt", "consent");
1056        }
1057    }
1058    if let Some(resource) = resource {
1059        set(&mut params, "resource", resource);
1060    }
1061    url.query_pairs_mut().clear().extend_pairs(&params);
1062    Ok((url, pkce.verifier))
1063}
1064
1065async fn run_flow(
1066    provider: &Provider<'_>,
1067    options: &FlowOptions,
1068) -> Result<FlowResult, OAuthError> {
1069    let fetch = options.fetch;
1070    let metadata_url = options
1071        .authorization_server_metadata_url
1072        .as_deref()
1073        .map(secure_endpoint)
1074        .transpose()?;
1075    // With a configured metadata URL, discovery is not cached, so changing it applies at once.
1076    let cached = match metadata_url {
1077        Some(_) => None,
1078        None => provider.stored("discovery").await?,
1079    };
1080    let discovered = match cached.as_ref().and_then(|cached| {
1081        truthy(cached, "authorizationServerUrl").map(|url| (cached, url.to_owned()))
1082    }) {
1083        Some((cached, url)) => {
1084            let metadata = match cached
1085                .get("authorizationServerMetadata")
1086                .and_then(Value::as_object)
1087            {
1088                Some(metadata) => Some(metadata.clone()),
1089                None => discover_authorization_server_metadata(&url, fetch).await?,
1090            };
1091            let resource = cached
1092                .get("resourceMetadata")
1093                .and_then(Value::as_object)
1094                .cloned();
1095            server_info(url, metadata, resource)
1096        }
1097        None => {
1098            discover_server_info(
1099                &options.server_url,
1100                options.resource_metadata_url.as_deref(),
1101                metadata_url.as_ref(),
1102                fetch,
1103            )
1104            .await?
1105        }
1106    };
1107    if metadata_url.is_none() {
1108        let mut discovery = discovered.clone();
1109        if let Some(url) = &options.resource_metadata_url {
1110            discovery.insert("resourceMetadataUrl".into(), Value::String(url.clone()));
1111        }
1112        provider
1113            .update(|state| {
1114                state.insert("discovery".into(), Value::Object(discovery));
1115            })
1116            .await?;
1117    }
1118    let authorization_server = text(&discovered, "authorizationServerUrl")
1119        .unwrap_or_default()
1120        .to_owned();
1121    let metadata = discovered
1122        .get("authorizationServerMetadata")
1123        .and_then(Value::as_object);
1124    let resource_metadata = discovered
1125        .get("resourceMetadata")
1126        .and_then(Value::as_object);
1127    let resource = select_resource(&options.server_url, resource_metadata)?;
1128    // An empty scope falls through to the next source.
1129    let scope = options
1130        .scope
1131        .clone()
1132        .filter(|scope| !scope.is_empty())
1133        .or_else(|| {
1134            resource_metadata
1135                .and_then(|resource| list(resource, "scopes_supported"))
1136                .map(|scopes| scopes.join(" "))
1137                .filter(|scope| !scope.is_empty())
1138        })
1139        .or_else(|| truthy(&provider.client_metadata, "scope").map(str::to_owned));
1140    let client = match provider.client_information().await? {
1141        Some(client) => client,
1142        None => {
1143            if options.authorization_code.is_some() {
1144                return Err(other(
1145                    "OAuth client information is missing during code exchange",
1146                ));
1147            }
1148            let client = register_client(
1149                &authorization_server,
1150                metadata,
1151                &provider.client_metadata,
1152                scope.as_deref(),
1153                fetch,
1154            )
1155            .await?;
1156            provider.save_client_information(client.clone()).await?;
1157            client
1158        }
1159    };
1160    let request = TokenRequest {
1161        authorization_server_url: &authorization_server,
1162        metadata,
1163        client: &client,
1164        resource: resource.as_deref(),
1165        fetch,
1166    };
1167    if let Some(code) = &options.authorization_code {
1168        // RFC 9207: never send a code from another authorization server to this one.
1169        if let Some(metadata) = metadata
1170            && (options.iss.is_some()
1171                || metadata.get("authorization_response_iss_parameter_supported")
1172                    == Some(&Value::Bool(true)))
1173        {
1174            let issuer = text(metadata, "issuer").unwrap_or_default();
1175            if options.iss.as_deref() != Some(issuer) {
1176                return Err(issuer_mismatch(issuer, options.iss.as_deref()));
1177            }
1178        }
1179        let verifier = provider.code_verifier().await?;
1180        let tokens = request
1181            .send(vec![
1182                ("grant_type".into(), "authorization_code".into()),
1183                ("code".into(), code.clone()),
1184                ("code_verifier".into(), verifier),
1185                ("redirect_uri".into(), provider.redirect_url.clone()),
1186            ])
1187            .await?;
1188        provider
1189            .save_tokens(with_scope(tokens, scope.as_deref()))
1190            .await?;
1191        return Ok(FlowResult::Authorized);
1192    }
1193    let existing = match options.skip_refresh {
1194        true => None,
1195        false => provider.stored("tokens").await?,
1196    };
1197    if let Some(existing) = &existing
1198        && let Some(refresh_token) = truthy(existing, "refresh_token")
1199    {
1200        let refreshed = request
1201            .send(vec![
1202                ("grant_type".into(), "refresh_token".into()),
1203                ("refresh_token".into(), refresh_token.to_owned()),
1204            ])
1205            .await;
1206        match refreshed {
1207            Ok(tokens) => {
1208                // `{refresh_token, ...tokens}`: a new refresh token replaces the old one in place.
1209                let mut merged = Object::new();
1210                merged.insert("refresh_token".into(), Value::from(refresh_token));
1211                merged.extend(tokens);
1212                // A refresh without `scope` keeps the scope of the grant.
1213                provider
1214                    .save_tokens(with_scope(merged, text(existing, "scope")))
1215                    .await?;
1216                return Ok(FlowResult::Authorized);
1217            }
1218            Err(error @ OAuthError::InsecureEndpoint(_)) => return Err(error),
1219            Err(OAuthError::Response { code, message }) if code != "server_error" => {
1220                return Err(OAuthError::Response { code, message });
1221            }
1222            Err(_) => {}
1223        }
1224    }
1225    let state = provider.state().await?;
1226    let (url, verifier) = start_authorization(
1227        &authorization_server,
1228        metadata,
1229        &client,
1230        &provider.redirect_url,
1231        scope.as_deref(),
1232        &state,
1233        resource.as_deref(),
1234    )?;
1235    provider
1236        .update(|state| {
1237            state.insert("codeVerifier".into(), Value::String(verifier));
1238        })
1239        .await?;
1240    Ok(FlowResult::Redirect(url.to_string()))
1241}
1242
1243/// pi's `authorizeMcp`: exchanges a code, refreshes stored tokens, or starts
1244/// an authorization. A rejected client registers again and a rejected grant
1245/// is dropped, then the flow runs once more.
1246pub async fn authorize(
1247    provider: &Provider<'_>,
1248    options: &FlowOptions,
1249) -> Result<FlowResult, OAuthError> {
1250    match run_flow(provider, options).await {
1251        Err(OAuthError::Response { code, .. })
1252            if code == "invalid_client" || code == "unauthorized_client" =>
1253        {
1254            provider.invalidate(Invalidate::All).await?;
1255            run_flow(provider, options).await
1256        }
1257        Err(OAuthError::Response { code, .. }) if code == "invalid_grant" => {
1258            provider.invalidate(Invalidate::Tokens).await?;
1259            run_flow(provider, options).await
1260        }
1261        result => result,
1262    }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268    use serde_json::json;
1269
1270    #[test]
1271    fn parses_challenges_like_pi() {
1272        let challenge = parse_www_authenticate(Some(
1273            r#"Bearer resource_metadata="https://x.test/.well-known/oauth-protected-resource/mcp", scope="", error=insufficient_scope"#,
1274        ));
1275        assert_eq!(
1276            challenge,
1277            Challenge {
1278                resource_metadata_url: Some(
1279                    "https://x.test/.well-known/oauth-protected-resource/mcp".into()
1280                ),
1281                scope: None,
1282                error: Some("insufficient_scope".into()),
1283            }
1284        );
1285        assert_eq!(
1286            parse_www_authenticate(Some("Basic realm=x")),
1287            Challenge::default()
1288        );
1289        assert!(is_insufficient_scope(Some(
1290            "Bearer error=\"insufficient_scope\""
1291        )));
1292        assert!(!is_insufficient_scope(Some("Bearer error=invalid_token")));
1293    }
1294
1295    #[test]
1296    fn builds_discovery_urls() {
1297        let urls = discovery_urls("https://auth.test/tenant/").unwrap();
1298        let urls: Vec<String> = urls.into_iter().map(String::from).collect();
1299        assert_eq!(
1300            urls,
1301            [
1302                "https://auth.test/.well-known/oauth-authorization-server/tenant",
1303                "https://auth.test/.well-known/openid-configuration/tenant",
1304                "https://auth.test/tenant/.well-known/openid-configuration",
1305            ]
1306        );
1307        assert_eq!(discovery_urls("https://auth.test").unwrap().len(), 2);
1308    }
1309
1310    #[test]
1311    fn selects_protected_resource() {
1312        let metadata = |resource: &str| {
1313            json!({"resource": resource})
1314                .as_object()
1315                .cloned()
1316                .unwrap_or_default()
1317        };
1318        assert_eq!(
1319            select_resource("https://x.test/mcp#a", Some(&metadata("https://x.test/"))),
1320            Ok(Some("https://x.test/".into()))
1321        );
1322        assert_eq!(
1323            select_resource(
1324                "https://x.test/mcp",
1325                Some(&metadata("https://x.test/other"))
1326            ),
1327            Err(other(
1328                "Protected resource https://x.test/other does not match MCP server https://x.test/mcp"
1329            ))
1330        );
1331        assert_eq!(select_resource("https://x.test/mcp", None), Ok(None));
1332    }
1333
1334    #[test]
1335    fn parses_tokens_and_clients() {
1336        let tokens = parse_tokens(&json!({
1337            "token_type": "Bearer", "access_token": "a", "scope": "", "refresh_token": null, "expires_in": "60",
1338        }))
1339        .unwrap();
1340        assert_eq!(
1341            Value::Object(tokens),
1342            json!({"access_token": "a", "token_type": "Bearer", "expires_in": 60})
1343        );
1344        assert_eq!(
1345            parse_tokens(&json!({"access_token": "a", "token_type": "Bearer", "expires_in": "x"})),
1346            Err(other("Invalid expires_in"))
1347        );
1348        let client = parse_client_information(&json!({
1349            "client_name": "n", "client_secret": "", "client_id": "c", "extra": 1,
1350        }))
1351        .unwrap();
1352        assert_eq!(
1353            yapi_types::json::stringify(&client),
1354            r#"{"client_name":"n","client_id":"c","extra":1,"redirect_uris":[]}"#
1355        );
1356    }
1357
1358    #[test]
1359    fn picks_client_authentication() {
1360        let client = |value: Value| value.as_object().cloned().unwrap_or_default();
1361        let secret = client(json!({"client_id": "c", "client_secret": "s"}));
1362        assert!(select_client_auth(&secret, &[]) == ClientAuth::Basic);
1363        assert!(select_client_auth(&secret, &["client_secret_post"]) == ClientAuth::Post);
1364        let public = client(json!({"client_id": "c"}));
1365        assert!(select_client_auth(&public, &["client_secret_basic"]) == ClientAuth::None);
1366        let hinted = client(
1367            json!({"client_id": "c", "client_secret": "s", "token_endpoint_auth_method": "client_secret_post"}),
1368        );
1369        assert!(select_client_auth(&hinted, &[]) == ClientAuth::Post);
1370    }
1371
1372    #[test]
1373    fn credentials_https_or_loopback() {
1374        assert!(secure_endpoint("http://auth.test/token").is_err());
1375        assert!(secure_endpoint("http://127.0.0.1:1/token").is_ok());
1376    }
1377
1378    #[test]
1379    fn authorization_web_only() {
1380        let start = |endpoint: &str| {
1381            let metadata = json!({
1382                "authorization_endpoint": endpoint,
1383                "response_types_supported": ["code"],
1384            });
1385            let metadata = metadata.as_object().cloned();
1386            start_authorization(
1387                "https://auth.test",
1388                metadata.as_ref(),
1389                &Object::new(),
1390                "http://127.0.0.1:1/callback",
1391                None,
1392                "state",
1393                None,
1394            )
1395        };
1396        assert!(start("https://auth.test/authorize").is_ok());
1397        for endpoint in [
1398            "file:///Applications/Calculator.app",
1399            "smb://host/share",
1400            "vscode://x",
1401        ] {
1402            assert!(start(endpoint).is_err(), "{endpoint}");
1403        }
1404    }
1405}