1use 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
22pub type Object = Map<String, Value>;
24
25#[derive(Clone, Debug, PartialEq, thiserror::Error)]
27pub enum OAuthError {
28 #[error("{message}")]
30 Response {
31 code: String,
33 message: String,
35 },
36 #[error("Refusing to send OAuth credentials to non-HTTPS endpoint {0}")]
38 InsecureEndpoint(String),
39 #[error("{0}")]
41 Network(String),
42 #[error("{0}")]
44 Other(String),
45}
46
47fn other(message: impl Into<String>) -> OAuthError {
48 OAuthError::Other(message.into())
49}
50
51fn 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
61fn 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
71fn 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
115fn 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
224fn 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 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
290pub(super) fn text<'a>(object: &'a Object, key: &str) -> Option<&'a str> {
292 object.get(key).and_then(Value::as_str)
293}
294
295fn 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
313pub struct Challenge {
314 pub resource_metadata_url: Option<String>,
316 pub scope: Option<String>,
318 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 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
333pub 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
356pub 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#[derive(Clone, Copy, Debug, Default)]
369pub struct Fetch {
370 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 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
412fn is_discovery_miss(status: u16) -> bool {
414 (400..500).contains(&status) || status == 502
415}
416
417fn 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
465fn 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 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
513fn 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
568fn 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
598fn is_loopback(url: &Url) -> bool {
603 matches!(
604 url.host_str(),
605 Some("localhost" | "127.0.0.1" | "[::1]" | "::1")
606 )
607}
608
609pub 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
662fn 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
668struct 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 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
791fn 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#[derive(Clone, Debug, Default)]
803pub struct FlowOptions {
804 pub server_url: String,
806 pub authorization_code: Option<String>,
808 pub iss: Option<String>,
810 pub scope: Option<String>,
812 pub resource_metadata_url: Option<String>,
814 pub authorization_server_metadata_url: Option<String>,
816 pub skip_refresh: bool,
818 pub fetch: Fetch,
820}
821
822#[derive(Clone, Debug, PartialEq, Eq)]
824pub enum FlowResult {
825 Authorized,
827 Redirect(String),
829}
830
831#[derive(Clone, Copy, Debug, PartialEq, Eq)]
833enum Invalidate {
834 All,
835 Tokens,
836}
837
838pub 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 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 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 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 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 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(¶ms);
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 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 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 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 let mut merged = Object::new();
1210 merged.insert("refresh_token".into(), Value::from(refresh_token));
1211 merged.extend(tokens);
1212 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
1243pub 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}