1use chrono::{DateTime, Duration, Utc};
14use secrecy::SecretString;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18#[derive(Debug, Clone, Deserialize)]
20pub struct ProtectedResourceMetadata {
21 #[serde(default)]
23 pub authorization_servers: Vec<String>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
28pub struct AuthServerMetadata {
29 pub issuer: String,
31 pub token_endpoint: String,
33 #[serde(default)]
35 pub device_authorization_endpoint: Option<String>,
36 #[serde(default)]
38 pub registration_endpoint: Option<String>,
39 #[serde(default)]
41 pub scopes_supported: Vec<String>,
42}
43
44#[derive(Debug, Error)]
46pub enum OAuthError {
47 #[error("no `resource_metadata` in WWW-Authenticate challenge")]
49 NoResourceMetadata,
50 #[error("no authorization server advertised by the resource")]
52 NoAuthorizationServer,
53 #[error("authorization server does not advertise a device_authorization_endpoint")]
55 NoDeviceEndpoint,
56 #[error("HTTP error: {0}")]
58 Http(String),
59 #[error("malformed response: {0}")]
61 Malformed(String),
62 #[error("oauth error: {0}")]
66 Oauth(String),
67}
68
69pub(crate) fn parse_www_authenticate(value: &str) -> Option<String> {
73 let idx = value.find("resource_metadata")?;
74 let rest = value[idx + "resource_metadata".len()..]
75 .trim_start()
76 .strip_prefix('=')?
77 .trim_start();
78 let url = if let Some(quoted) = rest.strip_prefix('"') {
79 quoted.split('"').next()?
80 } else {
81 rest.split([',', ' ']).next()?
82 };
83 (!url.is_empty()).then(|| url.to_string())
84}
85
86pub(crate) fn require_web_url(raw: &str) -> Result<(), OAuthError> {
101 let parsed = url::Url::parse(raw)
102 .map_err(|e| OAuthError::Oauth(format!("invalid discovery URL {raw:?}: {e}")))?;
103 if !parsed.username().is_empty() || parsed.password().is_some() {
104 return Err(OAuthError::Oauth(format!(
105 "discovery URL must not carry credentials: {raw}"
106 )));
107 }
108 let host = parsed
109 .host()
110 .ok_or_else(|| OAuthError::Oauth(format!("discovery URL has no host: {raw}")))?;
111 if parsed.host_str().unwrap_or("").is_empty() {
112 return Err(OAuthError::Oauth(format!(
113 "discovery URL has no host: {raw}"
114 )));
115 }
116 match parsed.scheme() {
117 "https" => {
118 if host_is_private(&host) {
119 return Err(OAuthError::Oauth(format!(
120 "refusing discovery URL at a private/link-local/loopback address: {raw}"
121 )));
122 }
123 Ok(())
124 }
125 "http" if host_is_loopback(&host) => Ok(()),
126 "http" => Err(OAuthError::Oauth(format!(
127 "refusing non-loopback http discovery URL (use https): {raw}"
128 ))),
129 other => Err(OAuthError::Oauth(format!(
130 "refusing discovery URL with non-http(s) scheme {other:?}: {raw}"
131 ))),
132 }
133}
134
135fn host_is_loopback(host: &url::Host<&str>) -> bool {
137 match host {
138 url::Host::Domain(d) => d.eq_ignore_ascii_case("localhost"),
139 url::Host::Ipv4(ip) => ip.is_loopback(),
140 url::Host::Ipv6(ip) => ip.is_loopback(),
141 }
142}
143
144fn host_is_private(host: &url::Host<&str>) -> bool {
147 match host {
148 url::Host::Domain(_) => false,
149 url::Host::Ipv4(ip) => {
150 ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_unspecified()
151 }
152 url::Host::Ipv6(ip) => {
153 let seg0 = ip.segments()[0];
154 ip.is_loopback()
155 || ip.is_unspecified()
156 || (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 }
159 }
160}
161
162pub async fn discover(
165 http: &reqwest::Client,
166 www_authenticate: &str,
167) -> Result<AuthServerMetadata, OAuthError> {
168 let resource_meta_url =
169 parse_www_authenticate(www_authenticate).ok_or(OAuthError::NoResourceMetadata)?;
170 require_web_url(&resource_meta_url)?;
171 let prm: ProtectedResourceMetadata = http
172 .get(&resource_meta_url)
173 .send()
174 .await
175 .map_err(|e| OAuthError::Http(e.to_string()))?
176 .error_for_status()
177 .map_err(|e| OAuthError::Http(e.to_string()))?
178 .json()
179 .await
180 .map_err(|e| OAuthError::Malformed(e.to_string()))?;
181 let as_base = prm
182 .authorization_servers
183 .into_iter()
184 .next()
185 .ok_or(OAuthError::NoAuthorizationServer)?;
186 fetch_as_metadata(http, &as_base).await
187}
188
189pub async fn fetch_as_metadata(
196 http: &reqwest::Client,
197 issuer: &str,
198) -> Result<AuthServerMetadata, OAuthError> {
199 require_web_url(issuer)?;
200 let well_known = well_known_url(issuer)?;
201 let meta: AuthServerMetadata = http
202 .get(&well_known)
203 .send()
204 .await
205 .map_err(|e| OAuthError::Http(e.to_string()))?
206 .error_for_status()
207 .map_err(|e| OAuthError::Http(e.to_string()))?
208 .json()
209 .await
210 .map_err(|e| OAuthError::Malformed(e.to_string()))?;
211 if meta.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') {
213 return Err(OAuthError::Malformed(format!(
214 "issuer mismatch: requested {issuer:?}, metadata declares {:?}",
215 meta.issuer
216 )));
217 }
218 require_web_url(&meta.token_endpoint)?;
220 if let Some(ep) = &meta.device_authorization_endpoint {
221 require_web_url(ep)?;
222 }
223 if let Some(ep) = &meta.registration_endpoint {
224 require_web_url(ep)?;
225 }
226 Ok(meta)
227}
228
229fn well_known_url(issuer: &str) -> Result<String, OAuthError> {
232 let parsed = url::Url::parse(issuer)
233 .map_err(|e| OAuthError::Oauth(format!("invalid issuer {issuer:?}: {e}")))?;
234 let authority = match parsed
239 .host()
240 .ok_or_else(|| OAuthError::Oauth(format!("issuer has no host: {issuer}")))?
241 {
242 url::Host::Ipv6(addr) => format!("[{addr}]"),
243 host => host.to_string(),
244 };
245 let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
246 let path = parsed.path().trim_end_matches('/'); Ok(format!(
248 "{}://{}{}/.well-known/oauth-authorization-server{}",
249 parsed.scheme(),
250 authority,
251 port,
252 path
253 ))
254}
255
256pub(crate) const GRANT_DEVICE_CODE: &str = "urn:ietf:params:oauth:grant-type:device_code";
258pub(crate) const GRANT_REFRESH_TOKEN: &str = "refresh_token";
259
260#[derive(Debug, Serialize)]
269struct ClientRegistrationRequest {
270 client_name: String,
271 grant_types: Vec<String>,
272 token_endpoint_auth_method: String,
273 redirect_uris: Vec<String>,
274}
275
276#[derive(Debug, Clone, Deserialize)]
278pub struct ClientRegistrationResponse {
279 pub client_id: String,
280}
281
282pub async fn register_client(
285 http: &reqwest::Client,
286 registration_endpoint: &str,
287 client_name: &str,
288) -> Result<String, OAuthError> {
289 let req = ClientRegistrationRequest {
290 client_name: client_name.to_string(),
291 grant_types: vec![
292 GRANT_DEVICE_CODE.to_string(),
293 GRANT_REFRESH_TOKEN.to_string(),
294 ],
295 token_endpoint_auth_method: "none".to_string(),
296 redirect_uris: vec!["urn:ietf:wg:oauth:2.0:oob".to_string()],
297 };
298 let resp: ClientRegistrationResponse = http
299 .post(registration_endpoint)
300 .json(&req)
301 .send()
302 .await
303 .map_err(|e| OAuthError::Http(e.to_string()))?
304 .error_for_status()
305 .map_err(|e| OAuthError::Http(e.to_string()))?
306 .json()
307 .await
308 .map_err(|e| OAuthError::Malformed(e.to_string()))?;
309 Ok(resp.client_id)
310}
311
312#[derive(Debug, Clone, Deserialize)]
314pub struct DeviceAuthResponse {
315 pub device_code: String,
316 pub user_code: String,
317 pub verification_uri: String,
318 #[serde(default)]
319 pub verification_uri_complete: Option<String>,
320 pub expires_in: i64,
321 #[serde(default = "default_interval")]
323 pub interval: u64,
324}
325
326fn default_interval() -> u64 {
327 5
328}
329
330#[derive(Debug, Clone, Deserialize)]
332pub struct TokenResponse {
333 pub access_token: SecretString,
334 #[serde(default)]
335 pub refresh_token: Option<SecretString>,
336 #[serde(default)]
338 pub expires_in: Option<i64>,
339 #[serde(default)]
340 pub token_type: Option<String>,
341}
342
343#[derive(Debug)]
345pub enum DevicePollOutcome {
346 Pending,
348 SlowDown,
350 Granted(TokenResponse),
352}
353
354pub async fn request_device_authorization(
356 http: &reqwest::Client,
357 device_endpoint: &str,
358 client_id: &str,
359 scope: Option<&str>,
360 resource: Option<&str>,
361) -> Result<DeviceAuthResponse, OAuthError> {
362 let mut form: Vec<(&str, &str)> = vec![("client_id", client_id)];
363 if let Some(s) = scope {
364 form.push(("scope", s));
365 }
366 if let Some(r) = resource {
369 form.push(("resource", r));
370 }
371 http.post(device_endpoint)
372 .form(&form)
373 .send()
374 .await
375 .map_err(|e| OAuthError::Http(e.to_string()))?
376 .error_for_status()
377 .map_err(|e| OAuthError::Http(e.to_string()))?
378 .json()
379 .await
380 .map_err(|e| OAuthError::Malformed(e.to_string()))
381}
382
383pub async fn poll_device_token_once(
387 http: &reqwest::Client,
388 token_endpoint: &str,
389 device_code: &str,
390 client_id: &str,
391 resource: Option<&str>,
392) -> Result<DevicePollOutcome, OAuthError> {
393 let mut form: Vec<(&str, &str)> = vec![
394 ("grant_type", GRANT_DEVICE_CODE),
395 ("device_code", device_code),
396 ("client_id", client_id),
397 ];
398 if let Some(r) = resource {
399 form.push(("resource", r));
400 }
401 let resp = http
402 .post(token_endpoint)
403 .form(&form)
404 .send()
405 .await
406 .map_err(|e| OAuthError::Http(e.to_string()))?;
407 let status = resp.status();
408 let body = resp
409 .text()
410 .await
411 .map_err(|e| OAuthError::Http(e.to_string()))?;
412 if status.is_success() {
413 let tokens: TokenResponse = serde_json::from_str(&body).map_err(|_| {
414 OAuthError::Malformed("authorization server returned a malformed token response".into())
416 })?;
417 return Ok(DevicePollOutcome::Granted(tokens));
418 }
419 match parse_oauth_error(&body).as_deref() {
420 Some("authorization_pending") => Ok(DevicePollOutcome::Pending),
421 Some("slow_down") => Ok(DevicePollOutcome::SlowDown),
422 Some(other) => Err(OAuthError::Oauth(other.to_string())),
423 None => Err(OAuthError::Oauth(format!("HTTP {status}: {body}"))),
424 }
425}
426
427pub async fn refresh(
435 http: &reqwest::Client,
436 token_endpoint: &str,
437 refresh_token: &str,
438 client_id: &str,
439 resource: Option<&str>,
440) -> Result<TokenResponse, OAuthError> {
441 let mut form: Vec<(&str, &str)> = vec![
442 ("grant_type", GRANT_REFRESH_TOKEN),
443 ("refresh_token", refresh_token),
444 ("client_id", client_id),
445 ];
446 if let Some(r) = resource {
449 form.push(("resource", r));
450 }
451 let resp = http
452 .post(token_endpoint)
453 .form(&form)
454 .send()
455 .await
456 .map_err(|e| OAuthError::Http(e.to_string()))?;
457 let status = resp.status();
458 let body = resp
459 .text()
460 .await
461 .map_err(|e| OAuthError::Http(e.to_string()))?;
462 if status.is_success() {
463 return serde_json::from_str(&body).map_err(|_| {
465 OAuthError::Malformed("authorization server returned a malformed token response".into())
466 });
467 }
468 match parse_oauth_error(&body) {
469 Some(code) => Err(OAuthError::Oauth(code)),
470 None => Err(OAuthError::Oauth(format!("HTTP {status}: {body}"))),
471 }
472}
473
474mod secret_serde {
479 use secrecy::{ExposeSecret, SecretString};
480 use serde::{Deserialize, Deserializer, Serializer};
481
482 pub fn serialize<S: Serializer>(s: &SecretString, ser: S) -> Result<S::Ok, S::Error> {
483 ser.serialize_str(s.expose_secret())
484 }
485 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<SecretString, D::Error> {
486 Ok(SecretString::from(String::deserialize(de)?))
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct OAuthTokens {
494 #[serde(with = "secret_serde")]
495 pub access_token: SecretString,
496 #[serde(with = "secret_serde")]
497 pub refresh_token: SecretString,
498 pub expires_at: DateTime<Utc>,
500}
501
502impl OAuthTokens {
503 pub fn from_response(
507 resp: TokenResponse,
508 now: DateTime<Utc>,
509 prev_refresh: Option<&str>,
510 ) -> Result<Self, OAuthError> {
511 let refresh_token = resp
512 .refresh_token
513 .or_else(|| prev_refresh.map(|s| SecretString::from(s.to_string())))
514 .ok_or_else(|| OAuthError::Malformed("token response has no refresh_token".into()))?;
515 const MAX_TTL_SECS: i64 = 400 * 24 * 60 * 60;
521 let ttl = resp.expires_in.unwrap_or(3600).clamp(0, MAX_TTL_SECS);
522 Ok(Self {
523 access_token: resp.access_token,
524 refresh_token,
525 expires_at: now + Duration::seconds(ttl),
526 })
527 }
528
529 pub fn is_near_expiry(&self, now: DateTime<Utc>, skew: Duration) -> bool {
532 self.expires_at <= now + skew
533 }
534}
535
536pub(crate) fn parse_oauth_error(body: &str) -> Option<String> {
538 #[derive(Deserialize)]
539 struct ErrBody {
540 error: String,
541 }
542 serde_json::from_str::<ErrBody>(body).ok().map(|e| e.error)
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use secrecy::ExposeSecret;
549
550 #[test]
551 fn parse_www_authenticate_quoted() {
552 let v = r#"Bearer resource_metadata="https://app.devboy.pro/.well-known/oauth-protected-resource""#;
553 assert_eq!(
554 parse_www_authenticate(v).as_deref(),
555 Some("https://app.devboy.pro/.well-known/oauth-protected-resource")
556 );
557 }
558
559 #[test]
560 fn parse_www_authenticate_ignores_other_params() {
561 let v = r#"Bearer realm="mcp", resource_metadata="https://x/y", error="invalid_token""#;
562 assert_eq!(parse_www_authenticate(v).as_deref(), Some("https://x/y"));
563 }
564
565 #[test]
566 fn parse_www_authenticate_bare_value() {
567 let v = "Bearer resource_metadata=https://x/y error=foo";
568 assert_eq!(parse_www_authenticate(v).as_deref(), Some("https://x/y"));
569 }
570
571 #[test]
572 fn parse_www_authenticate_absent() {
573 assert!(parse_www_authenticate("Bearer realm=\"x\"").is_none());
574 }
575
576 #[test]
577 fn require_web_url_enforces_https_and_loopback() {
578 assert!(require_web_url("https://as.example.com/.well-known/x").is_ok());
580 assert!(require_web_url("http://localhost:8080/meta").is_ok());
582 assert!(require_web_url("http://127.0.0.1/meta").is_ok());
583 assert!(require_web_url("http://[::1]:9000/meta").is_ok());
584 assert!(require_web_url("http://evil.internal/meta").is_err());
586 assert!(require_web_url("http://169.254.169.254/latest/meta-data").is_err());
587 assert!(require_web_url("file:///etc/passwd").is_err());
589 assert!(require_web_url("ftp://host/x").is_err());
590 assert!(require_web_url("gopher://host").is_err());
591 assert!(require_web_url("not a url").is_err());
593 }
594
595 #[test]
596 fn require_web_url_blocks_the_reviewed_bypasses() {
597 assert!(require_web_url("https://169.254.169.254/latest/meta-data").is_err());
600 assert!(require_web_url("https://10.0.0.5/token").is_err());
601 assert!(require_web_url("https://192.168.1.1/x").is_err());
602 assert!(require_web_url("https://172.16.0.1/x").is_err());
603 assert!(require_web_url("https://127.0.0.1/x").is_err());
604 assert!(require_web_url("https://[::1]/x").is_err());
605 assert!(require_web_url("https://[fd00::1]/x").is_err()); assert!(require_web_url("https://[fe80::1]/x").is_err()); assert!(require_web_url("http://localhost:80@evil.com/x").is_err());
609 assert!(require_web_url("http://localhost@evil.com/x").is_err());
610 assert!(require_web_url("https://as.example.com@evil.com/x").is_err());
611 assert!(require_web_url("http://127.0.0.1.evil.com/x").is_err());
613 assert!(require_web_url("http://localhost.evil.com/x").is_err());
614 }
615
616 #[test]
617 fn well_known_url_is_rfc8414_path_aware() {
618 assert_eq!(
620 well_known_url("https://as.example.com").unwrap(),
621 "https://as.example.com/.well-known/oauth-authorization-server"
622 );
623 assert_eq!(
625 well_known_url("https://as.example.com/").unwrap(),
626 "https://as.example.com/.well-known/oauth-authorization-server"
627 );
628 assert_eq!(
630 well_known_url("https://ex.com/tenant1").unwrap(),
631 "https://ex.com/.well-known/oauth-authorization-server/tenant1"
632 );
633 assert_eq!(
635 well_known_url("https://ex.com:8443/t").unwrap(),
636 "https://ex.com:8443/.well-known/oauth-authorization-server/t"
637 );
638 assert_eq!(
641 well_known_url("http://[::1]:8080").unwrap(),
642 "http://[::1]:8080/.well-known/oauth-authorization-server"
643 );
644 }
645
646 #[test]
647 fn as_metadata_deserializes() {
648 let json = r#"{
649 "issuer": "https://as.example.com",
650 "token_endpoint": "https://as.example.com/token",
651 "device_authorization_endpoint": "https://as.example.com/device",
652 "registration_endpoint": "https://as.example.com/register",
653 "scopes_supported": ["mcp:read", "mcp:write"]
654 }"#;
655 let m: AuthServerMetadata = serde_json::from_str(json).unwrap();
656 assert_eq!(m.token_endpoint, "https://as.example.com/token");
657 assert_eq!(
658 m.device_authorization_endpoint.as_deref(),
659 Some("https://as.example.com/device")
660 );
661 assert_eq!(m.scopes_supported.len(), 2);
662 }
663
664 #[test]
665 fn as_metadata_minimal_optional_fields_default() {
666 let json = r#"{"issuer":"https://as","token_endpoint":"https://as/token"}"#;
667 let m: AuthServerMetadata = serde_json::from_str(json).unwrap();
668 assert!(m.device_authorization_endpoint.is_none());
669 assert!(m.registration_endpoint.is_none());
670 assert!(m.scopes_supported.is_empty());
671 }
672
673 #[test]
674 fn protected_resource_metadata_deserializes() {
675 let json = r#"{"authorization_servers": ["https://as.example.com"]}"#;
676 let m: ProtectedResourceMetadata = serde_json::from_str(json).unwrap();
677 assert_eq!(m.authorization_servers, vec!["https://as.example.com"]);
678 }
679
680 #[test]
681 fn registration_request_is_public_device_client() {
682 let req = ClientRegistrationRequest {
683 client_name: "devboy-cli".into(),
684 grant_types: vec![GRANT_DEVICE_CODE.into(), GRANT_REFRESH_TOKEN.into()],
685 token_endpoint_auth_method: "none".into(),
686 redirect_uris: vec!["urn:ietf:wg:oauth:2.0:oob".into()],
687 };
688 let v = serde_json::to_value(&req).unwrap();
689 assert_eq!(v["token_endpoint_auth_method"], "none");
690 assert_eq!(v["grant_types"][0], GRANT_DEVICE_CODE);
691 assert_eq!(v["grant_types"][1], "refresh_token");
692 assert_eq!(v["redirect_uris"][0], "urn:ietf:wg:oauth:2.0:oob");
693 }
694
695 #[test]
696 fn registration_response_deserializes() {
697 let json = r#"{"client_id": "cli-xyz", "client_id_issued_at": 123, "client_secret": null}"#;
698 let r: ClientRegistrationResponse = serde_json::from_str(json).unwrap();
699 assert_eq!(r.client_id, "cli-xyz");
700 }
701
702 #[test]
703 fn device_auth_response_deserializes_with_defaults() {
704 let json = r#"{
705 "device_code": "dc123",
706 "user_code": "WDJB-MJHT",
707 "verification_uri": "https://as/device",
708 "verification_uri_complete": "https://as/device?user_code=WDJB-MJHT",
709 "expires_in": 900,
710 "interval": 5
711 }"#;
712 let d: DeviceAuthResponse = serde_json::from_str(json).unwrap();
713 assert_eq!(d.user_code, "WDJB-MJHT");
714 assert_eq!(d.interval, 5);
715 assert_eq!(
716 d.verification_uri_complete.as_deref(),
717 Some("https://as/device?user_code=WDJB-MJHT")
718 );
719 }
720
721 #[test]
722 fn device_auth_interval_defaults_to_5() {
723 let json = r#"{"device_code":"d","user_code":"U","verification_uri":"https://as/d","expires_in":600}"#;
724 let d: DeviceAuthResponse = serde_json::from_str(json).unwrap();
725 assert_eq!(d.interval, 5);
726 assert!(d.verification_uri_complete.is_none());
727 }
728
729 #[test]
730 fn token_response_deserializes() {
731 let json = r#"{"access_token":"at","refresh_token":"rt","expires_in":7776000,"token_type":"Bearer"}"#;
732 let t: TokenResponse = serde_json::from_str(json).unwrap();
733 assert_eq!(t.access_token.expose_secret(), "at");
734 assert_eq!(
735 t.refresh_token.as_ref().map(|s| s.expose_secret()),
736 Some("rt")
737 );
738 assert_eq!(t.expires_in, Some(7776000));
739 }
740
741 #[test]
742 fn parse_oauth_error_extracts_code() {
743 assert_eq!(
744 parse_oauth_error(r#"{"error":"authorization_pending"}"#).as_deref(),
745 Some("authorization_pending")
746 );
747 assert_eq!(
748 parse_oauth_error(r#"{"error":"slow_down","error_description":"…"}"#).as_deref(),
749 Some("slow_down")
750 );
751 assert!(parse_oauth_error("not json").is_none());
752 }
753
754 #[tokio::test]
755 async fn refresh_returns_rotated_pair_on_success() {
756 use httpmock::prelude::*;
757 let server = MockServer::start_async().await;
758 let m = server
759 .mock_async(|when, then| {
760 when.method(POST).path("/token");
761 then.status(200).header("content-type", "application/json").body(
762 r#"{"access_token":"new-at","refresh_token":"new-rt","expires_in":7776000,"token_type":"Bearer"}"#,
763 );
764 })
765 .await;
766 let http = reqwest::Client::new();
767 let t = refresh(
768 &http,
769 &format!("{}/token", server.base_url()),
770 "old-rt",
771 "cli-x",
772 None,
773 )
774 .await
775 .unwrap();
776 m.assert_async().await;
777 assert_eq!(t.access_token.expose_secret(), "new-at");
778 assert_eq!(
779 t.refresh_token.as_ref().map(|s| s.expose_secret()),
780 Some("new-rt")
781 ); }
783
784 #[tokio::test]
785 async fn refresh_invalid_grant_surfaces_error() {
786 use httpmock::prelude::*;
787 let server = MockServer::start_async().await;
788 server
789 .mock_async(|when, then| {
790 when.method(POST).path("/token");
791 then.status(400)
792 .header("content-type", "application/json")
793 .body(r#"{"error":"invalid_grant"}"#);
794 })
795 .await;
796 let http = reqwest::Client::new();
797 let err = refresh(
798 &http,
799 &format!("{}/token", server.base_url()),
800 "spent",
801 "cli-x",
802 None,
803 )
804 .await
805 .unwrap_err();
806 assert!(matches!(err, OAuthError::Oauth(c) if c == "invalid_grant"));
807 }
808
809 fn epoch() -> DateTime<Utc> {
810 DateTime::from_timestamp(1_700_000_000, 0).unwrap()
811 }
812
813 #[test]
814 fn oauth_tokens_from_response_computes_expiry() {
815 let resp = TokenResponse {
816 access_token: "at".into(),
817 refresh_token: Some("rt".into()),
818 expires_in: Some(3600),
819 token_type: Some("Bearer".into()),
820 };
821 let t = OAuthTokens::from_response(resp, epoch(), None).unwrap();
822 assert_eq!(t.access_token.expose_secret(), "at");
823 assert_eq!(t.refresh_token.expose_secret(), "rt");
824 assert_eq!(t.expires_at, epoch() + Duration::seconds(3600));
825 }
826
827 #[test]
828 fn from_response_clamps_out_of_range_expires_in() {
829 let resp = TokenResponse {
832 access_token: "at".into(),
833 refresh_token: Some("rt".into()),
834 expires_in: Some(i64::MAX),
835 token_type: Some("Bearer".into()),
836 };
837 let t = OAuthTokens::from_response(resp, epoch(), None).expect("must not panic");
838 assert!(t.expires_at <= epoch() + Duration::days(400));
840 assert!(t.expires_at > epoch());
841 }
842
843 #[test]
844 fn oauth_tokens_falls_back_to_prev_refresh() {
845 let resp = TokenResponse {
846 access_token: "at".into(),
847 refresh_token: None,
848 expires_in: Some(60),
849 token_type: None,
850 };
851 let t = OAuthTokens::from_response(resp, epoch(), Some("old-rt")).unwrap();
852 assert_eq!(t.refresh_token.expose_secret(), "old-rt");
853 }
854
855 #[test]
856 fn oauth_tokens_no_refresh_anywhere_errors() {
857 let resp = TokenResponse {
858 access_token: "at".into(),
859 refresh_token: None,
860 expires_in: Some(60),
861 token_type: None,
862 };
863 assert!(OAuthTokens::from_response(resp, epoch(), None).is_err());
864 }
865
866 #[test]
867 fn oauth_tokens_near_expiry() {
868 let t = OAuthTokens {
869 access_token: "at".into(),
870 refresh_token: "rt".into(),
871 expires_at: epoch() + Duration::seconds(100),
872 };
873 assert!(t.is_near_expiry(epoch() + Duration::seconds(50), Duration::seconds(60)));
875 assert!(!t.is_near_expiry(epoch() + Duration::seconds(10), Duration::seconds(60)));
877 }
878
879 #[test]
880 fn oauth_tokens_json_roundtrip() {
881 let t = OAuthTokens {
882 access_token: "at".into(),
883 refresh_token: "rt".into(),
884 expires_at: epoch(),
885 };
886 let json = serde_json::to_string(&t).unwrap();
887 let back: OAuthTokens = serde_json::from_str(&json).unwrap();
888 assert_eq!(back.access_token.expose_secret(), "at");
889 assert_eq!(back.expires_at, epoch());
890 }
891}