1use chrono::{DateTime, Utc};
2use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderValue, LOCATION};
3use secrecy::{ExposeSecret as _, SecretString};
4use serde::Deserialize;
5use url::Url;
6
7use crate::auth::discovery::{Discovery, discover};
8use crate::error::{Result, YtError};
9use crate::instance::InstanceUrl;
10
11pub const HUB_SCOPE: &str = "0-0-0-0-0";
13
14pub fn scope(service_id: &str, revocable: bool) -> String {
15 if revocable {
16 format!("{service_id} {HUB_SCOPE}")
17 } else {
18 service_id.to_string()
19 }
20}
21
22pub struct AuthorizeRequest<'a> {
23 pub client_id: &'a str,
24 pub redirect_uri: &'a str,
25 pub scope: &'a str,
26 pub state: &'a str,
27 pub challenge: &'a str,
28}
29
30pub fn authorize_url(discovery: &Discovery, request: &AuthorizeRequest<'_>) -> Url {
31 let mut url = discovery.authorization_endpoint.clone();
32 url.query_pairs_mut()
33 .append_pair("response_type", "code")
34 .append_pair("client_id", request.client_id)
35 .append_pair("redirect_uri", request.redirect_uri)
36 .append_pair("scope", request.scope)
37 .append_pair("state", request.state)
38 .append_pair("code_challenge", request.challenge)
39 .append_pair("code_challenge_method", "S256")
40 .append_pair("access_type", "offline")
41 .append_pair("request_credentials", "default");
42 url
43}
44
45pub async fn preflight(
48 no_redirects: &reqwest::Client,
49 discovery: &Discovery,
50 authorize: &Url,
51) -> Result<()> {
52 let response = no_redirects
53 .get(authorize.clone())
54 .send()
55 .await
56 .map_err(network)?;
57 let status = response.status();
58 let Some(location) = response
59 .headers()
60 .get(LOCATION)
61 .and_then(|value| value.to_str().ok())
62 else {
63 return if status.is_success() {
64 Ok(())
65 } else {
66 Err(YtError::OAuth {
67 message: format!("the sign-in page answered HTTP {status}"),
68 help: None,
69 }
70 .into())
71 };
72 };
73 let Ok(target) = authorize.join(location) else {
74 return Ok(());
75 };
76 if !target.path().ends_with("/auth/oauth/error") {
77 return Ok(());
78 }
79 let token = target
80 .query_pairs()
81 .find(|(key, _)| key == "message_token")
82 .map(|(_, value)| value.into_owned());
83 let reason = match token {
84 Some(token) => hub_message(no_redirects, &discovery.hub, &token).await,
85 None => None,
86 };
87 let (error, description) =
88 reason.unwrap_or_else(|| ("rejected".into(), "Hub rejected the sign-in request".into()));
89 let message = if description.is_empty() {
90 error.clone()
91 } else {
92 description
93 };
94 Err(YtError::OAuth {
95 message,
96 help: help_for(&error),
97 }
98 .into())
99}
100
101pub fn help_for(error: &str) -> Option<String> {
102 let help = match error {
103 "invalid_request" | "invalid_client" | "unauthorized_client" => {
104 "check the OAuth client ID your administrator registered for yt"
105 }
106 "invalid_redirect_uri" => {
107 "the OAuth client must allow the redirect URI http://127.0.0.1:<any port>/callback"
108 }
109 "access_denied" => "the sign-in was not approved",
110 "invalid_grant" => "start `yt auth login` again",
111 _ => return None,
112 };
113 Some(help.to_string())
114}
115
116#[derive(Debug)]
117pub struct Tokens {
118 pub access_token: SecretString,
119 pub refresh_token: Option<SecretString>,
120 pub expires_in: i64,
121}
122
123pub enum Refresh {
124 Fresh(Tokens),
125 Expired,
126}
127
128pub async fn exchange_code(
129 http: &reqwest::Client,
130 discovery: &Discovery,
131 client_id: &str,
132 code: &str,
133 redirect_uri: &str,
134 verifier: &SecretString,
135) -> Result<Tokens> {
136 let form = [
137 ("grant_type", "authorization_code"),
138 ("code", code),
139 ("redirect_uri", redirect_uri),
140 ("code_verifier", verifier.expose_secret()),
141 ("client_id", client_id),
142 ];
143 let response = http
144 .post(discovery.token_endpoint.clone())
145 .header(ACCEPT, "application/json")
146 .form(&form)
147 .send()
148 .await
149 .map_err(network)?;
150 read_tokens(response)
151 .await
152 .map_err(|failure| match failure {
153 TokenFailure::Rejected { error, description } => {
154 let message = if description.is_empty() {
155 error.clone()
156 } else {
157 description
158 };
159 YtError::OAuth {
160 message,
161 help: help_for(&error),
162 }
163 .into()
164 }
165 TokenFailure::Other(report) => report,
166 })
167}
168
169pub async fn refresh(
170 http: &reqwest::Client,
171 token_endpoint: &Url,
172 client_id: &str,
173 refresh_token: &SecretString,
174 scope: &str,
175) -> Result<Refresh> {
176 let form = [
177 ("grant_type", "refresh_token"),
178 ("refresh_token", refresh_token.expose_secret()),
179 ("scope", scope),
180 ("client_id", client_id),
181 ];
182 let response = http
183 .post(token_endpoint.clone())
184 .header(ACCEPT, "application/json")
185 .form(&form)
186 .send()
187 .await
188 .map_err(network)?;
189 match read_tokens(response).await {
190 Ok(tokens) => Ok(Refresh::Fresh(tokens)),
191 Err(TokenFailure::Rejected { error, .. }) if error == "invalid_grant" => {
192 Ok(Refresh::Expired)
193 }
194 Err(TokenFailure::Rejected { error, description }) => Err(YtError::OAuth {
195 message: format!("{error}: {description}"),
196 help: help_for(&error),
197 }
198 .into()),
199 Err(TokenFailure::Other(report)) => Err(report),
200 }
201}
202
203#[derive(Clone, Debug, PartialEq)]
204pub struct HubRefreshToken {
205 pub id: String,
206 pub created: i64,
207 pub expires: Option<i64>,
208 pub client_key: Option<String>,
209}
210
211pub async fn list_refresh_tokens(
212 http: &reqwest::Client,
213 hub: &Url,
214 access: &SecretString,
215) -> Result<Vec<HubRefreshToken>> {
216 #[derive(Deserialize)]
217 struct Listing {
218 #[serde(default)]
219 refreshtokens: Vec<Item>,
220 }
221 #[derive(Deserialize)]
222 #[serde(rename_all = "camelCase")]
223 struct Item {
224 id: String,
225 creation_time: Option<i64>,
226 expires: Option<i64>,
227 client: Option<ClientRef>,
228 }
229 #[derive(Deserialize)]
230 struct ClientRef {
231 key: Option<String>,
232 }
233
234 let mut url = hub
235 .join("api/rest/users/me/refreshtokens")
236 .map_err(|error| YtError::OAuth {
237 message: error.to_string(),
238 help: None,
239 })?;
240 url.query_pairs_mut()
241 .append_pair("fields", "id,creationTime,expires,client(key)")
242 .append_pair("$top", "100");
243 let response = http
244 .get(url)
245 .header(ACCEPT, "application/json")
246 .header(AUTHORIZATION, bearer(access))
247 .send()
248 .await
249 .map_err(network)?;
250 let status = response.status();
251 let text = response.text().await.map_err(network)?;
252 if !status.is_success() {
253 return Err(YtError::OAuth {
254 message: crate::api::error::unexpected(status, &text),
255 help: Some("revoking sign-ins needs `yt auth login --revocable`".into()),
256 }
257 .into());
258 }
259 let listing: Listing = serde_json::from_str(&text).map_err(|error| YtError::OAuth {
260 message: error.to_string(),
261 help: None,
262 })?;
263 Ok(listing
264 .refreshtokens
265 .into_iter()
266 .map(|item| HubRefreshToken {
267 id: item.id,
268 created: item.creation_time.unwrap_or(0),
269 expires: item.expires,
270 client_key: item.client.and_then(|client| client.key),
271 })
272 .collect())
273}
274
275pub async fn delete_refresh_token(
276 http: &reqwest::Client,
277 hub: &Url,
278 access: &SecretString,
279 id: &str,
280) -> Result<()> {
281 let url = hub
282 .join(&format!("api/rest/users/me/refreshtokens/{id}"))
283 .map_err(|error| YtError::OAuth {
284 message: error.to_string(),
285 help: None,
286 })?;
287 let response = http
288 .delete(url)
289 .header(AUTHORIZATION, bearer(access))
290 .send()
291 .await
292 .map_err(network)?;
293 let status = response.status();
294 if status.is_success() {
295 Ok(())
296 } else {
297 let text = response.text().await.unwrap_or_default();
298 Err(YtError::OAuth {
299 message: crate::api::error::unexpected(status, &text),
300 help: None,
301 }
302 .into())
303 }
304}
305
306pub fn find_issued<'a>(
308 tokens: &'a [HubRefreshToken],
309 client_id: &str,
310 created: DateTime<Utc>,
311) -> Option<&'a HubRefreshToken> {
312 let target = created.timestamp_millis();
313 tokens
314 .iter()
315 .filter(|token| token.client_key.as_deref() == Some(client_id))
316 .filter(|token| (token.created - target).abs() <= 120_000)
317 .min_by_key(|token| (token.created - target).abs())
318}
319
320pub async fn issued_refresh_token(
322 http: &reqwest::Client,
323 url: &InstanceUrl,
324 access: &SecretString,
325 client_id: &str,
326 created: DateTime<Utc>,
327) -> Result<Option<(Url, HubRefreshToken)>> {
328 let discovery = discover(http, url).await?;
329 let tokens = list_refresh_tokens(http, &discovery.hub, access).await?;
330 Ok(find_issued(&tokens, client_id, created)
331 .cloned()
332 .map(|token| (discovery.hub, token)))
333}
334
335enum TokenFailure {
336 Rejected { error: String, description: String },
337 Other(miette::Report),
338}
339
340async fn read_tokens(response: reqwest::Response) -> std::result::Result<Tokens, TokenFailure> {
341 #[derive(Deserialize)]
342 struct TokenBody {
343 access_token: String,
344 refresh_token: Option<String>,
345 expires_in: Option<i64>,
346 }
347 #[derive(Deserialize)]
348 struct TokenError {
349 error: String,
350 error_description: Option<String>,
351 }
352
353 let status = response.status();
354 let text = response
355 .text()
356 .await
357 .map_err(|error| TokenFailure::Other(network(error)))?;
358 if status.is_success() {
359 let body: TokenBody = serde_json::from_str(&text).map_err(|_| {
360 TokenFailure::Other(
361 YtError::OAuth {
362 message: "the token endpoint returned an unexpected response".into(),
363 help: None,
364 }
365 .into(),
366 )
367 })?;
368 crate::redact::register(&body.access_token);
369 if let Some(refresh_token) = &body.refresh_token {
370 crate::redact::register(refresh_token);
371 }
372 return Ok(Tokens {
373 access_token: SecretString::from(body.access_token),
374 refresh_token: body.refresh_token.map(SecretString::from),
375 expires_in: body.expires_in.unwrap_or(3600).clamp(0, 24 * 60 * 60),
377 });
378 }
379 match serde_json::from_str::<TokenError>(&text) {
380 Ok(error) => Err(TokenFailure::Rejected {
381 error: error.error,
382 description: error.error_description.unwrap_or_default(),
383 }),
384 Err(_) => Err(TokenFailure::Other(
385 YtError::OAuth {
386 message: crate::api::error::unexpected(status, &text),
387 help: None,
388 }
389 .into(),
390 )),
391 }
392}
393
394async fn hub_message(http: &reqwest::Client, hub: &Url, token: &str) -> Option<(String, String)> {
395 #[derive(Deserialize)]
396 struct Message {
397 error: Option<String>,
398 error_description: Option<String>,
399 }
400 let mut url = hub.join("api/rest/oauth/message").ok()?;
401 url.query_pairs_mut().append_pair("token", token);
402 let message: Message = http
403 .get(url)
404 .header(ACCEPT, "application/json")
405 .send()
406 .await
407 .ok()?
408 .json()
409 .await
410 .ok()?;
411 Some((
412 message.error.unwrap_or_else(|| "error".into()),
413 message.error_description.unwrap_or_default(),
414 ))
415}
416
417fn bearer(token: &SecretString) -> HeaderValue {
418 let mut value = HeaderValue::from_str(&format!("Bearer {}", token.expose_secret()))
419 .unwrap_or_else(|_| HeaderValue::from_static("Bearer invalid"));
420 value.set_sensitive(true);
421 value
422}
423
424fn network(error: reqwest::Error) -> miette::Report {
425 YtError::Network(crate::http::describe(&error)).into()
426}
427
428#[cfg(test)]
429mod tests {
430 use serde_json::json;
431 use wiremock::matchers::{body_string_contains, method, path, query_param};
432 use wiremock::{Mock, MockServer, ResponseTemplate};
433
434 use super::*;
435
436 fn http(redirects: bool) -> reqwest::Client {
437 let config = crate::http::HttpConfig {
438 timeout: std::time::Duration::from_secs(5),
439 retries: 0,
440 ca_file: None,
441 };
442 if redirects {
443 crate::http::client(&config).unwrap()
444 } else {
445 crate::http::client_without_redirects(&config).unwrap()
446 }
447 }
448
449 fn discovery(server: &MockServer) -> Discovery {
450 let hub = Url::parse(&format!("{}/hub/", server.uri())).unwrap();
451 Discovery {
452 url: InstanceUrl::parse(&server.uri()).unwrap(),
453 version: "2026.2".into(),
454 service_id: "svc".into(),
455 authorization_endpoint: hub.join("api/rest/oauth2/auth").unwrap(),
456 token_endpoint: hub.join("api/rest/oauth2/token").unwrap(),
457 hub,
458 embedded_hub: true,
459 cimd_supported: false,
460 }
461 }
462
463 fn request<'a>() -> AuthorizeRequest<'a> {
464 AuthorizeRequest {
465 client_id: "yt-cli",
466 redirect_uri: "http://127.0.0.1:5000/callback",
467 scope: "svc",
468 state: "st4te",
469 challenge: "ch4llenge",
470 }
471 }
472
473 #[tokio::test]
474 async fn the_authorize_url_carries_pkce_and_offline_access() {
475 let server = MockServer::start().await;
476 let url = authorize_url(&discovery(&server), &request());
477 let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
478 assert_eq!(query["response_type"], "code");
479 assert_eq!(query["client_id"], "yt-cli");
480 assert_eq!(query["redirect_uri"], "http://127.0.0.1:5000/callback");
481 assert_eq!(query["code_challenge_method"], "S256");
482 assert_eq!(query["access_type"], "offline");
483 assert_eq!(query["request_credentials"], "default");
484 assert_eq!(scope("svc", true), "svc 0-0-0-0-0");
485 assert_eq!(scope("svc", false), "svc");
486 }
487
488 #[tokio::test]
489 async fn preflight_accepts_a_redirect_to_the_login_page() {
490 let server = MockServer::start().await;
491 Mock::given(method("GET"))
492 .and(path("/hub/api/rest/oauth2/auth"))
493 .respond_with(
494 ResponseTemplate::new(303).insert_header("location", "/hub/auth/login?flow=x"),
495 )
496 .mount(&server)
497 .await;
498 let discovery = discovery(&server);
499 preflight(
500 &http(false),
501 &discovery,
502 &authorize_url(&discovery, &request()),
503 )
504 .await
505 .unwrap();
506 }
507
508 #[tokio::test]
509 async fn preflight_reports_the_hub_reason_for_a_rejection() {
510 let server = MockServer::start().await;
511 Mock::given(method("GET"))
512 .and(path("/hub/api/rest/oauth2/auth"))
513 .respond_with(ResponseTemplate::new(303).insert_header(
514 "location",
515 "/hub/auth/oauth/error?message_token=T1&client_id=yt-cli",
516 ))
517 .mount(&server)
518 .await;
519 Mock::given(method("GET"))
520 .and(path("/hub/api/rest/oauth/message"))
521 .and(query_param("token", "T1"))
522 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
523 "error": "invalid_redirect_uri",
524 "error_description": "Unregistered Redirect URI http://localhost:5000/callback"
525 })))
526 .mount(&server)
527 .await;
528 let discovery = discovery(&server);
529 let error = preflight(
530 &http(false),
531 &discovery,
532 &authorize_url(&discovery, &request()),
533 )
534 .await
535 .unwrap_err();
536 assert!(
537 error.to_string().contains("Unregistered Redirect URI"),
538 "{error}"
539 );
540 let help = error
541 .help()
542 .map(|help| help.to_string())
543 .unwrap_or_default();
544 assert!(
545 help.contains("http://127.0.0.1:<any port>/callback"),
546 "{help}"
547 );
548 }
549
550 #[tokio::test]
551 async fn a_huge_lifetime_is_capped_at_a_day() {
552 let server = MockServer::start().await;
553 Mock::given(method("POST"))
554 .and(path("/hub/api/rest/oauth2/token"))
555 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
556 "access_token": "access-cap-0123456789",
557 "expires_in": i64::MAX,
558 })))
559 .mount(&server)
560 .await;
561 let verifier = SecretString::from("v3rifier".to_string());
562 let tokens = exchange_code(
563 &http(true),
564 &discovery(&server),
565 "yt-cli",
566 "c0de",
567 "http://127.0.0.1:5000/callback",
568 &verifier,
569 )
570 .await
571 .unwrap();
572 assert_eq!(tokens.expires_in, 24 * 60 * 60);
573 }
574
575 #[tokio::test]
576 async fn exchanging_a_code_returns_tokens_and_registers_them_for_redaction() {
577 let server = MockServer::start().await;
578 Mock::given(method("POST"))
579 .and(path("/hub/api/rest/oauth2/token"))
580 .and(body_string_contains("grant_type=authorization_code"))
581 .and(body_string_contains("code_verifier=v3rifier"))
582 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
583 "access_token": "access-0123456789",
584 "refresh_token": "refresh-0123456789",
585 "expires_in": 3600,
586 "scope": "svc",
587 "token_type": "Bearer"
588 })))
589 .mount(&server)
590 .await;
591 let verifier = SecretString::from("v3rifier".to_string());
592 let tokens = exchange_code(
593 &http(true),
594 &discovery(&server),
595 "yt-cli",
596 "c0de",
597 "http://127.0.0.1:5000/callback",
598 &verifier,
599 )
600 .await
601 .unwrap();
602 assert_eq!(tokens.expires_in, 3600);
603 assert!(tokens.refresh_token.is_some());
604 assert_eq!(crate::redact::redact("access-0123456789"), "[REDACTED]");
605 }
606
607 #[tokio::test]
608 async fn a_rejected_code_is_an_oauth_error() {
609 let server = MockServer::start().await;
610 Mock::given(method("POST"))
611 .respond_with(ResponseTemplate::new(400).set_body_json(
612 json!({"error": "invalid_grant", "error_description": "Code expired"}),
613 ))
614 .mount(&server)
615 .await;
616 let verifier = SecretString::from("v3rifier".to_string());
617 let error = exchange_code(
618 &http(true),
619 &discovery(&server),
620 "yt-cli",
621 "c0de",
622 "http://x",
623 &verifier,
624 )
625 .await
626 .unwrap_err();
627 assert!(error.to_string().contains("Code expired"), "{error}");
628 }
629
630 #[tokio::test]
631 async fn an_invalid_grant_on_refresh_means_expired() {
632 let server = MockServer::start().await;
633 Mock::given(method("POST"))
634 .and(body_string_contains("grant_type=refresh_token"))
635 .respond_with(ResponseTemplate::new(400).set_body_json(
636 json!({"error": "invalid_grant", "error_description": "Unknown refresh token"}),
637 ))
638 .mount(&server)
639 .await;
640 let token = SecretString::from("refresh-0123456789".to_string());
641 let outcome = refresh(
642 &http(true),
643 &discovery(&server).token_endpoint,
644 "yt-cli",
645 &token,
646 "svc",
647 )
648 .await
649 .unwrap();
650 assert!(matches!(outcome, Refresh::Expired));
651 }
652
653 #[tokio::test]
654 async fn refresh_tokens_are_listed_and_deleted() {
655 let server = MockServer::start().await;
656 Mock::given(method("GET"))
657 .and(path("/hub/api/rest/users/me/refreshtokens"))
658 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
659 "type": "RefreshTokenPage", "skip": 0, "top": 100, "total": 1,
660 "refreshtokens": [{"id": "rt-1", "creationTime": 1_791_579_736_619_i64, "expires": 1_799_355_736_619_i64, "client": {"id": "uuid", "key": "yt-cli"}, "scope": ["svc"]}]
661 })))
662 .mount(&server)
663 .await;
664 Mock::given(method("DELETE"))
665 .and(path("/hub/api/rest/users/me/refreshtokens/rt-1"))
666 .respond_with(ResponseTemplate::new(200))
667 .expect(1)
668 .mount(&server)
669 .await;
670 let access = SecretString::from("access-0123456789".to_string());
671 let hub = discovery(&server).hub;
672 let tokens = list_refresh_tokens(&http(true), &hub, &access)
673 .await
674 .unwrap();
675 assert_eq!(
676 tokens,
677 vec![HubRefreshToken {
678 id: "rt-1".into(),
679 created: 1_791_579_736_619,
680 expires: Some(1_799_355_736_619),
681 client_key: Some("yt-cli".into())
682 }]
683 );
684 delete_refresh_token(&http(true), &hub, &access, "rt-1")
685 .await
686 .unwrap();
687 }
688
689 #[test]
690 fn find_issued_picks_the_same_client_closest_in_time() {
691 let created = DateTime::<Utc>::from_timestamp_millis(1_000_000_000).unwrap();
692 let tokens = vec![
693 HubRefreshToken {
694 id: "other-client".into(),
695 created: 1_000_000_100,
696 expires: None,
697 client_key: Some("other".into()),
698 },
699 HubRefreshToken {
700 id: "far".into(),
701 created: 1_000_500_000,
702 expires: None,
703 client_key: Some("yt-cli".into()),
704 },
705 HubRefreshToken {
706 id: "near".into(),
707 created: 1_000_001_500,
708 expires: None,
709 client_key: Some("yt-cli".into()),
710 },
711 ];
712 assert_eq!(
713 find_issued(&tokens, "yt-cli", created).map(|token| token.id.as_str()),
714 Some("near")
715 );
716 assert_eq!(find_issued(&tokens[..2], "yt-cli", created), None);
717 }
718
719 #[tokio::test]
720 async fn the_issued_refresh_token_is_found_through_discovery() {
721 let server = MockServer::start().await;
722 Mock::given(method("GET"))
723 .and(path("/api/config"))
724 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
725 "version": "2026.2",
726 "ring": {"url": "/hub", "serviceId": "svc", "hasEmbeddedHub": true}
727 })))
728 .mount(&server)
729 .await;
730 Mock::given(method("GET"))
731 .and(path("/hub/api/rest/users/me/refreshtokens"))
732 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
733 "refreshtokens": [{"id": "rt-1", "creationTime": 1_000_000_000_i64, "expires": 8_776_000_000_i64, "client": {"key": "yt-cli"}}]
734 })))
735 .mount(&server)
736 .await;
737 let url = InstanceUrl::parse(&server.uri()).unwrap();
738 let access = SecretString::from("access-0123456789".to_string());
739 let created = DateTime::<Utc>::from_timestamp_millis(1_000_000_000).unwrap();
740 let (hub, token) = issued_refresh_token(&http(true), &url, &access, "yt-cli", created)
741 .await
742 .unwrap()
743 .unwrap();
744 assert_eq!(hub.path(), "/hub/");
745 assert_eq!(token.expires, Some(8_776_000_000));
746 }
747}