Skip to main content

yt/auth/
oauth.rs

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
11/// Hub's own service ID; adding it to the scope lets yt manage its refresh tokens.
12pub 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
45/// Sends the authorize request without following redirects, so a rejected request fails here with
46/// Hub's reason; Hub's own error page can fail to render.
47pub 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
306/// The refresh token yt was issued at `created`: same client, closest in time, within two minutes.
307pub 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
320/// Finds the Hub and the refresh token of the sign-in made at `created`; needs a revocable sign-in.
321pub 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            // An absurd lifetime would overflow the expiry date; refreshing sooner is harmless.
376            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}