Skip to main content

yt/auth/
login.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use chrono::Utc;
5use secrecy::{ExposeSecret as _, SecretString};
6
7use crate::api::Client;
8use crate::auth::discovery::{Discovery, discover};
9use crate::auth::loopback::{self, Callback};
10use crate::auth::oauth::{self, AuthorizeRequest};
11use crate::auth::pkce::{Pkce, random_token};
12use crate::auth::token_source::StaticToken;
13use crate::config::file::{AuthKind, ConfigStore, GlobalConfig, Profile};
14use crate::credentials::{self, OauthCredential, StoredCredential};
15use crate::env::Env;
16use crate::error::{Result, YtError};
17use crate::http::HttpConfig;
18use crate::instance::InstanceUrl;
19use crate::ui::{Choice, Ui};
20
21/// The redirect port named when no listener runs (paste-back); any port is accepted on a registered loopback URI.
22const PASTE_PORT: u16 = 8765;
23
24/// yt's client ID where an instance allows automatic OAuth client registration (CIMD): the URL of its client metadata document, served from the `docs` branch. The URL must never change.
25pub const CIMD_CLIENT_ID: &str = "https://vaivars.github.io/rust-yt-cli/oauth-client.json";
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum Method {
29    Browser,
30    Token,
31}
32
33#[derive(Default)]
34pub struct LoginRequest {
35    pub url: Option<String>,
36    pub method: Option<Method>,
37    pub client_id: Option<String>,
38    pub profile: Option<String>,
39    pub token: Option<SecretString>,
40    pub paste: bool,
41    pub revocable: bool,
42}
43
44pub struct LoginEnv<'a> {
45    pub env: &'a Env,
46    pub ui: &'a Ui,
47    pub store: &'a ConfigStore,
48    pub http: &'a HttpConfig,
49    pub open_browser: &'a (dyn Fn(&str) -> bool + Send + Sync),
50}
51
52#[derive(Debug)]
53pub struct LoginOutcome {
54    pub profile: String,
55    pub url: InstanceUrl,
56    pub login: String,
57}
58
59#[derive(Clone, Copy)]
60enum Next {
61    ClientId,
62    Token,
63    Cancel,
64}
65
66/// Signs in: choose a method, find the instance, authenticate, then save the credential and profile.
67pub async fn login(le: &LoginEnv<'_>, request: LoginRequest) -> Result<LoginOutcome> {
68    let LoginRequest {
69        url,
70        method,
71        client_id,
72        profile,
73        token,
74        paste,
75        revocable,
76    } = request;
77    let config = le.store.load()?;
78    let mut method = match (token.is_some(), method) {
79        (true, _) => Method::Token,
80        (false, Some(method)) => method,
81        (false, None) => {
82            le.ui
83                .select("How do you want to sign in?", method_choices(), "--method")
84                .await?
85        }
86    };
87    if method == Method::Browser && !le.ui.is_interactive() {
88        return Err(YtError::Usage {
89            message: "browser sign-in needs a terminal; use --method token --token-stdin, or set YOUTRACK_URL and YOUTRACK_TOKEN".into(),
90        }
91        .into());
92    }
93    let http = crate::http::client(le.http)?;
94    let discovery = discover_with_prompt(le, &http, url).await?;
95    if discovery.url.is_unencrypted() {
96        log::warn!(
97            "{} uses plain http: the sign-in and everything yt sends cross the network unencrypted",
98            discovery.url
99        );
100    }
101    check_requested(&config, profile.as_deref(), &discovery.url, None)?;
102    let mut client_id = client_id
103        .or_else(|| client_id_for(&config, &discovery.url))
104        .or_else(|| discovery.cimd_supported.then(|| CIMD_CLIENT_ID.to_string()));
105    if method == Method::Browser && client_id.is_none() {
106        match ask_for_client(le, &discovery).await? {
107            Some(id) => client_id = Some(id),
108            None => method = Method::Token,
109        }
110    }
111    let (login, credential) = match (method, client_id) {
112        (Method::Browser, Some(client_id)) => {
113            browser_login(le, &http, &discovery, &client_id, paste, revocable).await?
114        }
115        _ => token_login(le, &http, &discovery, token).await?,
116    };
117    check_requested(&config, profile.as_deref(), &discovery.url, Some(&login))?;
118    let name = profile_name(le, &config, &discovery.url, &login, profile).await?;
119    let account = discovery.url.account(&login);
120    if revocable {
121        revoke_previous(&http, &discovery, &account, &credential).await;
122    }
123    credentials::save(&account, &credential).await?;
124    let previous = config.profiles.get(&name);
125    let first_for_url = !config.profiles.iter().any(|(other, existing)| {
126        other != &name && InstanceUrl::parse(&existing.url).ok().as_ref() == Some(&discovery.url)
127    });
128    let oauth = match &credential {
129        StoredCredential::Oauth(oauth) => Some(oauth),
130        StoredCredential::Token { .. } => None,
131    };
132    let profile = Profile {
133        url: discovery.url.to_string(),
134        login: Some(login.clone()),
135        auth: if oauth.is_some() {
136            AuthKind::Oauth
137        } else {
138            AuthKind::Token
139        },
140        project: previous.and_then(|existing| existing.project.clone()),
141        default_for_url: first_for_url || previous.is_some_and(|existing| existing.default_for_url),
142        oauth_client_id: oauth
143            .map(|oauth| oauth.client_id.clone())
144            .or_else(|| previous.and_then(|existing| existing.oauth_client_id.clone())),
145        ca_file: le.http.ca_file.clone(),
146        revocable: revocable && oauth.is_some(),
147    };
148    le.store.set_profile(&name, &profile)?;
149    if config.default_profile.is_none() {
150        le.store.set_default_profile(&name)?;
151    }
152    Ok(LoginOutcome {
153        profile: name,
154        url: discovery.url,
155        login,
156    })
157}
158
159fn method_choices() -> Vec<Choice<Method>> {
160    vec![
161        Choice {
162            value: Method::Browser,
163            label: "Browser sign-in (recommended)".into(),
164            description: Some("approve access in your browser".into()),
165        },
166        Choice {
167            value: Method::Token,
168            label: "Permanent token".into(),
169            description: Some("create a token in your YouTrack profile and paste it".into()),
170        },
171    ]
172}
173
174async fn discover_with_prompt(
175    le: &LoginEnv<'_>,
176    http: &reqwest::Client,
177    url: Option<String>,
178) -> Result<Discovery> {
179    let mut given = url;
180    loop {
181        let input = match given.take() {
182            Some(url) => url,
183            None => {
184                le.ui
185                    .input(
186                        "YouTrack URL",
187                        Some("the address you open YouTrack at"),
188                        None,
189                        "<URL>",
190                    )
191                    .await?
192            }
193        };
194        let result = match InstanceUrl::parse(&input) {
195            Ok(url) => {
196                le.ui
197                    .with_spinner("Checking the instance", discover(http, &url))
198                    .await
199            }
200            Err(error) => Err(error),
201        };
202        match result {
203            Ok(discovery) => return Ok(discovery),
204            Err(error) if le.ui.is_interactive() => crate::ui::eprint_line(&error.to_string()),
205            Err(error) => return Err(error),
206        }
207    }
208}
209
210/// Explains what an administrator must set up; `Some(id)` to continue in the browser, `None` to use a token.
211async fn ask_for_client(le: &LoginEnv<'_>, discovery: &Discovery) -> Result<Option<String>> {
212    crate::ui::eprint_line(&client_setup(&discovery.url));
213    let choices = vec![
214        Choice {
215            value: Next::ClientId,
216            label: "Enter a client ID from your administrator".into(),
217            description: None,
218        },
219        Choice {
220            value: Next::Token,
221            label: "Use a permanent token instead".into(),
222            description: None,
223        },
224        Choice {
225            value: Next::Cancel,
226            label: "Cancel".into(),
227            description: None,
228        },
229    ];
230    match le
231        .ui
232        .select("How do you want to continue?", choices, "--client-id")
233        .await?
234    {
235        Next::ClientId => Ok(Some(
236            le.ui.input("Client ID", None, None, "--client-id").await?,
237        )),
238        Next::Token => Ok(None),
239        Next::Cancel => Err(YtError::Cancelled.into()),
240    }
241}
242
243/// What an administrator sets up so yt can sign in through the browser.
244fn client_setup(url: &InstanceUrl) -> String {
245    format!(
246        "Browser sign-in needs an OAuth client for yt on {url}. An administrator sets it up under Administration › Access Management › OAuth Clients, either way:\n- turn on \"Allow automatic OAuth client registration via CIMD\", and yt registers itself, or\n- create a client: Authorization Code with PKCE, no client secret, redirect URI {} (any port works).\n",
247        loopback::redirect_uri(PASTE_PORT)
248    )
249}
250
251async fn browser_login(
252    le: &LoginEnv<'_>,
253    http: &reqwest::Client,
254    discovery: &Discovery,
255    client_id: &str,
256    paste: bool,
257    revocable: bool,
258) -> Result<(String, StoredCredential)> {
259    let pkce = Pkce::generate();
260    let state = random_token(16);
261    let scope = oauth::scope(&discovery.service_id, revocable);
262    let paste = paste || !crate::browser::can_launch(le.env);
263    let callback = if paste {
264        None
265    } else {
266        Some(Callback::bind().await?)
267    };
268    let redirect_uri = callback.as_ref().map_or_else(
269        || loopback::redirect_uri(PASTE_PORT),
270        Callback::redirect_uri,
271    );
272    let authorize = oauth::authorize_url(
273        discovery,
274        &AuthorizeRequest {
275            client_id,
276            redirect_uri: &redirect_uri,
277            scope: &scope,
278            state: &state,
279            challenge: &pkce.challenge,
280        },
281    );
282    oauth::preflight(
283        &crate::http::client_without_redirects(le.http)?,
284        discovery,
285        &authorize,
286    )
287    .await?;
288    crate::ui::eprint_line(&format!("Open this address to sign in:\n{authorize}\n"));
289    let code = match callback {
290        Some(callback) => {
291            (le.open_browser)(authorize.as_str());
292            le.ui
293                .with_spinner(
294                    "Waiting for the browser",
295                    callback.wait(&state, Duration::from_secs(300)),
296                )
297                .await?
298        }
299        None => {
300            let pasted = le
301                .ui
302                .input(
303                    "Address of the page your browser landed on",
304                    Some("after you approve, that page fails to load; copy its address from the address bar"),
305                    None,
306                    "a terminal",
307                )
308                .await?;
309            loopback::code_from_pasted(&pasted, &state)?
310        }
311    };
312    let tokens = oauth::exchange_code(
313        http,
314        discovery,
315        client_id,
316        &code,
317        &redirect_uri,
318        &pkce.verifier,
319    )
320    .await?;
321    let refresh_token = tokens.refresh_token.ok_or_else(|| YtError::OAuth {
322        message: "Hub issued no refresh token".into(),
323        help: None,
324    })?;
325    let access_token = tokens.access_token.expose_secret().to_string();
326    let login = verify(
327        le,
328        http,
329        discovery,
330        SecretString::from(access_token.clone()),
331    )
332    .await?;
333    let now = Utc::now();
334    Ok((
335        login,
336        StoredCredential::Oauth(OauthCredential {
337            access_token,
338            refresh_token: refresh_token.expose_secret().to_string(),
339            expires_at: now + chrono::Duration::seconds(tokens.expires_in),
340            client_id: client_id.to_string(),
341            token_endpoint: discovery.token_endpoint.to_string(),
342            scope,
343            created_at: now,
344        }),
345    ))
346}
347
348async fn token_login(
349    le: &LoginEnv<'_>,
350    http: &reqwest::Client,
351    discovery: &Discovery,
352    token: Option<SecretString>,
353) -> Result<(String, StoredCredential)> {
354    let token = match token {
355        Some(token) => token,
356        None => {
357            if le.ui.is_interactive() {
358                let page = discovery.token_page();
359                crate::ui::eprint_line(&format!(
360                    "Create a permanent token in your YouTrack profile:\n  {page}\n\n  1. Under Tokens, click New token.\n  2. Name it after this computer (e.g. yt on my laptop), keep the YouTrack scope, and click Create.\n  3. Copy the token.\n"
361                ));
362                (le.open_browser)(page.as_str());
363            }
364            le.ui
365                .secret("Paste the token", None, "--token-stdin")
366                .await?
367        }
368    };
369    let token_text = token.expose_secret().to_string();
370    let login = verify(le, http, discovery, token).await.map_err(|error| {
371        if matches!(
372            error.downcast_ref::<YtError>(),
373            Some(YtError::CredentialRejected { .. })
374        ) {
375            YtError::TokenRejected.into()
376        } else {
377            error
378        }
379    })?;
380    Ok((login, StoredCredential::Token { token: token_text }))
381}
382
383async fn verify(
384    le: &LoginEnv<'_>,
385    http: &reqwest::Client,
386    discovery: &Discovery,
387    token: SecretString,
388) -> Result<String> {
389    let source = Arc::new(StaticToken::new(token, discovery.url.to_string()));
390    let client = Client::new(http.clone(), discovery.url.clone(), source, le.http.retries);
391    Ok(crate::api::users::me(&client).await?.login)
392}
393
394async fn profile_name(
395    le: &LoginEnv<'_>,
396    config: &GlobalConfig,
397    url: &InstanceUrl,
398    login: &str,
399    requested: Option<String>,
400) -> Result<String> {
401    if let Some(name) = requested {
402        return Ok(name);
403    }
404    let existing = config.profiles.iter().find(|(_, profile)| {
405        InstanceUrl::parse(&profile.url).ok().as_ref() == Some(url)
406            && profile.login.as_deref() == Some(login)
407    });
408    if let Some((name, _)) = existing {
409        return Ok(name.clone());
410    }
411    let hint = free_name(config, &url.profile_name_hint());
412    if !le.ui.is_interactive() {
413        return Ok(hint);
414    }
415    loop {
416        let name = le
417            .ui
418            .input(
419                "Profile name",
420                Some("a short name to pick this account with --profile"),
421                Some(&hint),
422                "--profile",
423            )
424            .await?;
425        if name.is_empty() || name.chars().any(char::is_whitespace) {
426            crate::ui::eprint_line("Use one word without spaces.");
427            continue;
428        }
429        if config.profiles.contains_key(&name) {
430            let free = free_name(config, &name);
431            crate::ui::eprint_line(&format!(
432                "Profile '{name}' already exists for another account; try '{free}'."
433            ));
434            continue;
435        }
436        return Ok(name);
437    }
438}
439
440/// A profile named with `--profile` keeps its account: same instance, and the same login once it is known.
441fn check_requested(
442    config: &GlobalConfig,
443    requested: Option<&str>,
444    url: &InstanceUrl,
445    login: Option<&str>,
446) -> Result<()> {
447    let Some((name, existing)) = requested.and_then(|name| config.profiles.get_key_value(name))
448    else {
449        return Ok(());
450    };
451    let free = free_name(config, name);
452    if InstanceUrl::parse(&existing.url).ok().as_ref() != Some(url) {
453        return Err(YtError::Usage {
454            message: format!(
455                "profile '{name}' is for {}, not {url}; pick another --profile name, e.g. '{free}'",
456                existing.url
457            ),
458        }
459        .into());
460    }
461    if let (Some(login), Some(current)) = (login, existing.login.as_deref())
462        && current != login
463    {
464        return Err(YtError::Usage {
465            message: format!(
466                "profile '{name}' is signed in as {current}, but you signed in as {login}; pick another --profile name, e.g. '{free}'"
467            ),
468        }
469        .into());
470    }
471    Ok(())
472}
473
474fn free_name(config: &GlobalConfig, base: &str) -> String {
475    if !config.profiles.contains_key(base) {
476        return base.to_string();
477    }
478    (2..)
479        .map(|n| format!("{base}-{n}"))
480        .find(|name| !config.profiles.contains_key(name))
481        .unwrap_or_else(|| base.to_string())
482}
483
484fn client_id_for(config: &GlobalConfig, url: &InstanceUrl) -> Option<String> {
485    config
486        .profiles
487        .values()
488        .filter(|profile| InstanceUrl::parse(&profile.url).ok().as_ref() == Some(url))
489        .find_map(|profile| profile.oauth_client_id.clone())
490}
491
492/// Best effort: a leftover refresh token expires on its own after 90 days.
493async fn revoke_previous(
494    http: &reqwest::Client,
495    discovery: &Discovery,
496    account: &str,
497    credential: &StoredCredential,
498) {
499    let StoredCredential::Oauth(new) = credential else {
500        return;
501    };
502    let Ok(Some(StoredCredential::Oauth(old))) = credentials::load(account).await else {
503        return;
504    };
505    let access = SecretString::from(new.access_token.clone());
506    let result: Result<()> = async {
507        let tokens = oauth::list_refresh_tokens(http, &discovery.hub, &access).await?;
508        if let Some(token) = oauth::find_issued(&tokens, &old.client_id, old.created_at) {
509            oauth::delete_refresh_token(http, &discovery.hub, &access, &token.id).await?;
510        }
511        Ok(())
512    }
513    .await;
514    if let Err(error) = result {
515        log::warn!("the previous sign-in was not revoked: {error}");
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use std::io::{Read as _, Write as _};
522
523    use serde_json::json;
524    use url::Url;
525    use wiremock::matchers::{body_string_contains, method, path};
526    use wiremock::{Mock, MockServer, ResponseTemplate};
527
528    use super::*;
529
530    struct Fixture {
531        _dir: tempfile::TempDir,
532        store: ConfigStore,
533        http: HttpConfig,
534        env: Env,
535    }
536
537    fn fixture() -> Fixture {
538        keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
539        let dir = tempfile::tempdir().unwrap();
540        let store = ConfigStore::new(dir.path().join("config.toml"));
541        Fixture {
542            _dir: dir,
543            store,
544            http: HttpConfig {
545                timeout: Duration::from_secs(5),
546                retries: 0,
547                ca_file: None,
548            },
549            env: Env::from_vars([("DISPLAY", ":0")]),
550        }
551    }
552
553    async fn youtrack(users_me: ResponseTemplate) -> MockServer {
554        instance(users_me, false).await
555    }
556
557    async fn instance(users_me: ResponseTemplate, cimd: bool) -> MockServer {
558        let server = MockServer::start().await;
559        Mock::given(method("GET"))
560            .and(path("/api/config"))
561            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
562                "version": "2026.2",
563                "ring": {"url": "/hub", "serviceId": "svc", "hasEmbeddedHub": true}
564            })))
565            .mount(&server)
566            .await;
567        Mock::given(method("GET"))
568            .and(path("/hub/.well-known/oauth-authorization-server"))
569            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
570                "authorization_endpoint": format!("{}/hub/api/rest/oauth2/auth", server.uri()),
571                "token_endpoint": format!("{}/hub/api/rest/oauth2/token", server.uri()),
572                "client_id_metadata_document_supported": cimd
573            })))
574            .mount(&server)
575            .await;
576        Mock::given(method("GET"))
577            .and(path("/api/users/me"))
578            .respond_with(users_me)
579            .mount(&server)
580            .await;
581        server
582    }
583
584    fn jane() -> ResponseTemplate {
585        ResponseTemplate::new(200)
586            .set_body_json(json!({"id": "1-1", "login": "jane", "name": "Jane"}))
587    }
588
589    fn no_browser(_: &str) -> bool {
590        false
591    }
592
593    #[tokio::test]
594    async fn a_token_from_stdin_signs_in_without_prompts() {
595        let server = youtrack(jane()).await;
596        let f = fixture();
597        let ui = Ui::new(false, false);
598        let le = LoginEnv {
599            env: &f.env,
600            ui: &ui,
601            store: &f.store,
602            http: &f.http,
603            open_browser: &no_browser,
604        };
605        let request = LoginRequest {
606            url: Some(server.uri()),
607            token: Some(SecretString::from(
608                "perm-bG9naW4=.dGVzdA==.secret123456".to_string(),
609            )),
610            ..LoginRequest::default()
611        };
612        let outcome = login(&le, request).await.unwrap();
613        assert_eq!(outcome.login, "jane");
614        let config = f.store.load().unwrap();
615        let profile = &config.profiles[&outcome.profile];
616        assert_eq!(profile.auth, AuthKind::Token);
617        assert!(profile.default_for_url);
618        assert_eq!(
619            config.default_profile.as_deref(),
620            Some(outcome.profile.as_str())
621        );
622        let stored = credentials::load(&outcome.url.account("jane"))
623            .await
624            .unwrap();
625        assert!(matches!(stored, Some(StoredCredential::Token { .. })));
626    }
627
628    #[tokio::test]
629    async fn a_rejected_token_says_which_scope_is_needed() {
630        let server =
631            youtrack(ResponseTemplate::new(401).set_body_json(json!({"error": "Unauthorized"})))
632                .await;
633        let f = fixture();
634        let ui = Ui::new(false, false);
635        let le = LoginEnv {
636            env: &f.env,
637            ui: &ui,
638            store: &f.store,
639            http: &f.http,
640            open_browser: &no_browser,
641        };
642        let request = LoginRequest {
643            url: Some(server.uri()),
644            token: Some(SecretString::from(
645                "perm-YmFk.dGVzdA==.secret123456".to_string(),
646            )),
647            ..LoginRequest::default()
648        };
649        let error = login(&le, request).await.unwrap_err();
650        assert!(matches!(
651            error.downcast_ref::<YtError>(),
652            Some(YtError::TokenRejected)
653        ));
654    }
655
656    #[tokio::test]
657    async fn browser_sign_in_needs_a_terminal() {
658        let server = youtrack(jane()).await;
659        let f = fixture();
660        let ui = Ui::new(false, false);
661        let le = LoginEnv {
662            env: &f.env,
663            ui: &ui,
664            store: &f.store,
665            http: &f.http,
666            open_browser: &no_browser,
667        };
668        let request = LoginRequest {
669            url: Some(server.uri()),
670            method: Some(Method::Browser),
671            ..LoginRequest::default()
672        };
673        assert_eq!(
674            crate::error::exit_code(&login(&le, request).await.unwrap_err()),
675            2
676        );
677    }
678
679    #[tokio::test]
680    async fn a_token_sign_in_without_a_terminal_asks_for_stdin() {
681        let server = youtrack(jane()).await;
682        let f = fixture();
683        let ui = Ui::new(false, false);
684        let opened = |_: &str| -> bool { panic!("no browser without a terminal") };
685        let le = LoginEnv {
686            env: &f.env,
687            ui: &ui,
688            store: &f.store,
689            http: &f.http,
690            open_browser: &opened,
691        };
692        let request = LoginRequest {
693            url: Some(server.uri()),
694            method: Some(Method::Token),
695            ..LoginRequest::default()
696        };
697        let error = login(&le, request).await.unwrap_err();
698        let help = error
699            .help()
700            .map(|help| help.to_string())
701            .unwrap_or_default();
702        assert_eq!(help, "pass --token-stdin");
703    }
704
705    /// Plays the browser: follows the authorize URL straight to the redirect, as an approving user would.
706    fn approve(url: &str) -> bool {
707        let url = Url::parse(url).unwrap();
708        let query: std::collections::HashMap<String, String> =
709            url.query_pairs().into_owned().collect();
710        let redirect = Url::parse(&query["redirect_uri"]).unwrap();
711        let state = query["state"].clone();
712        std::thread::spawn(move || {
713            let mut stream =
714                std::net::TcpStream::connect(("127.0.0.1", redirect.port().unwrap())).unwrap();
715            write!(
716                stream,
717                "GET /callback?code=c0de&state={state} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
718            )
719            .unwrap();
720            let mut response = String::new();
721            let _ = stream.read_to_string(&mut response);
722        });
723        true
724    }
725
726    async fn oauth_endpoints(server: &MockServer) {
727        Mock::given(method("GET"))
728            .and(path("/hub/api/rest/oauth2/auth"))
729            .respond_with(ResponseTemplate::new(303).insert_header("location", "/hub/auth/login"))
730            .mount(server)
731            .await;
732        Mock::given(method("POST"))
733            .and(path("/hub/api/rest/oauth2/token"))
734            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
735                "access_token": "access-0123456789",
736                "refresh_token": "refresh-0123456789",
737                "expires_in": 3600,
738                "token_type": "Bearer"
739            })))
740            .mount(server)
741            .await;
742    }
743
744    #[tokio::test]
745    async fn browser_sign_in_stores_a_refreshable_credential() {
746        let server = youtrack(jane()).await;
747        oauth_endpoints(&server).await;
748        let f = fixture();
749        let ui = Ui::new(true, false);
750        let le = LoginEnv {
751            env: &f.env,
752            ui: &ui,
753            store: &f.store,
754            http: &f.http,
755            open_browser: &approve,
756        };
757        let request = LoginRequest {
758            url: Some(server.uri()),
759            method: Some(Method::Browser),
760            client_id: Some("yt-cli".into()),
761            profile: Some("acme".into()),
762            ..LoginRequest::default()
763        };
764        let outcome = login(&le, request).await.unwrap();
765        assert_eq!(outcome.profile, "acme");
766        let profile = &f.store.load().unwrap().profiles["acme"];
767        assert_eq!(profile.auth, AuthKind::Oauth);
768        assert_eq!(profile.oauth_client_id.as_deref(), Some("yt-cli"));
769        let Some(StoredCredential::Oauth(stored)) = credentials::load(&outcome.url.account("jane"))
770            .await
771            .unwrap()
772        else {
773            panic!("no oauth credential stored")
774        };
775        assert_eq!(stored.refresh_token, "refresh-0123456789");
776        assert_eq!(stored.scope, "svc");
777    }
778
779    #[tokio::test]
780    async fn a_revocable_sign_in_revokes_the_previous_one() {
781        let server = youtrack(jane()).await;
782        oauth_endpoints(&server).await;
783        let f = fixture();
784        let url = InstanceUrl::parse(&server.uri()).unwrap();
785        let earlier = Utc::now() - chrono::Duration::days(1);
786        credentials::save(
787            &url.account("jane"),
788            &StoredCredential::Oauth(OauthCredential {
789                access_token: "old-access-0123456789".into(),
790                refresh_token: "old-refresh-0123456789".into(),
791                expires_at: earlier,
792                client_id: "yt-cli".into(),
793                token_endpoint: format!("{}/hub/api/rest/oauth2/token", server.uri()),
794                scope: "svc 0-0-0-0-0".into(),
795                created_at: earlier,
796            }),
797        )
798        .await
799        .unwrap();
800        Mock::given(method("GET"))
801            .and(path("/hub/api/rest/users/me/refreshtokens"))
802            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
803                "refreshtokens": [{"id": "old-rt", "creationTime": earlier.timestamp_millis(), "client": {"key": "yt-cli"}}]
804            })))
805            .mount(&server)
806            .await;
807        Mock::given(method("DELETE"))
808            .and(path("/hub/api/rest/users/me/refreshtokens/old-rt"))
809            .respond_with(ResponseTemplate::new(200))
810            .expect(1)
811            .mount(&server)
812            .await;
813        let ui = Ui::new(true, false);
814        let le = LoginEnv {
815            env: &f.env,
816            ui: &ui,
817            store: &f.store,
818            http: &f.http,
819            open_browser: &approve,
820        };
821        let request = LoginRequest {
822            url: Some(server.uri()),
823            method: Some(Method::Browser),
824            client_id: Some("yt-cli".into()),
825            profile: Some("acme".into()),
826            revocable: true,
827            ..LoginRequest::default()
828        };
829        login(&le, request).await.unwrap();
830        assert!(f.store.load().unwrap().profiles["acme"].revocable);
831    }
832
833    /// Expects exactly one code exchange, made with `client_id`.
834    async fn oauth_endpoints_for(server: &MockServer, client_id: &str) {
835        let encoded: String = url::form_urlencoded::byte_serialize(client_id.as_bytes()).collect();
836        Mock::given(method("GET"))
837            .and(path("/hub/api/rest/oauth2/auth"))
838            .respond_with(ResponseTemplate::new(303).insert_header("location", "/hub/auth/login"))
839            .mount(server)
840            .await;
841        Mock::given(method("POST"))
842            .and(path("/hub/api/rest/oauth2/token"))
843            .and(body_string_contains(format!("client_id={encoded}")))
844            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
845                "access_token": "access-0123456789",
846                "refresh_token": "refresh-0123456789",
847                "expires_in": 3600,
848                "token_type": "Bearer"
849            })))
850            .expect(1)
851            .mount(server)
852            .await;
853    }
854
855    /// Signs in as profile `acme` through the browser and returns the client ID the authorize URL named.
856    async fn browser_sign_in(f: &Fixture, server: &MockServer, client_id: Option<&str>) -> String {
857        let seen = std::sync::Mutex::new(None);
858        let open = |url: &str| {
859            let query: std::collections::HashMap<String, String> = Url::parse(url)
860                .unwrap()
861                .query_pairs()
862                .into_owned()
863                .collect();
864            *seen.lock().unwrap() = query.get("client_id").cloned();
865            approve(url)
866        };
867        let ui = Ui::new(true, false);
868        let le = LoginEnv {
869            env: &f.env,
870            ui: &ui,
871            store: &f.store,
872            http: &f.http,
873            open_browser: &open,
874        };
875        let request = LoginRequest {
876            url: Some(server.uri()),
877            method: Some(Method::Browser),
878            client_id: client_id.map(str::to_string),
879            profile: Some("acme".into()),
880            ..LoginRequest::default()
881        };
882        login(&le, request).await.unwrap();
883        seen.into_inner()
884            .unwrap()
885            .expect("the browser was not opened")
886    }
887
888    #[tokio::test]
889    async fn with_automatic_registration_browser_sign_in_needs_no_client() {
890        let server = instance(jane(), true).await;
891        oauth_endpoints_for(&server, CIMD_CLIENT_ID).await;
892        let f = fixture();
893        assert_eq!(browser_sign_in(&f, &server, None).await, CIMD_CLIENT_ID);
894        let profile = &f.store.load().unwrap().profiles["acme"];
895        assert_eq!(profile.oauth_client_id.as_deref(), Some(CIMD_CLIENT_ID));
896    }
897
898    #[tokio::test]
899    async fn a_client_saved_for_the_instance_wins_over_automatic_registration() {
900        let server = instance(jane(), true).await;
901        oauth_endpoints_for(&server, "yt-cli").await;
902        let f = fixture();
903        let saved = Profile {
904            oauth_client_id: Some("yt-cli".into()),
905            ..existing(&server.uri(), "jane")
906        };
907        f.store.set_profile("acme", &saved).unwrap();
908        assert_eq!(browser_sign_in(&f, &server, None).await, "yt-cli");
909    }
910
911    #[tokio::test]
912    async fn the_client_id_flag_wins_over_automatic_registration() {
913        let server = instance(jane(), true).await;
914        oauth_endpoints_for(&server, "from-flag").await;
915        let f = fixture();
916        assert_eq!(
917            browser_sign_in(&f, &server, Some("from-flag")).await,
918            "from-flag"
919        );
920    }
921
922    #[test]
923    fn the_client_setup_help_offers_automatic_registration() {
924        let text = client_setup(&InstanceUrl::parse("https://acme.youtrack.cloud").unwrap());
925        assert!(
926            text.contains("Allow automatic OAuth client registration via CIMD"),
927            "{text}"
928        );
929        assert!(text.contains("http://127.0.0.1:8765/callback"), "{text}");
930    }
931
932    fn existing(url: &str, login: &str) -> Profile {
933        Profile {
934            url: url.into(),
935            login: Some(login.into()),
936            auth: AuthKind::Token,
937            project: Some("OPS".into()),
938            default_for_url: true,
939            oauth_client_id: None,
940            ca_file: None,
941            revocable: false,
942        }
943    }
944
945    #[tokio::test]
946    async fn a_requested_profile_of_another_account_is_left_alone() {
947        let server = youtrack(jane()).await;
948        let f = fixture();
949        f.store
950            .set_profile("acme", &existing(&server.uri(), "jane.admin"))
951            .unwrap();
952        let ui = Ui::new(false, false);
953        let le = LoginEnv {
954            env: &f.env,
955            ui: &ui,
956            store: &f.store,
957            http: &f.http,
958            open_browser: &no_browser,
959        };
960        let request = LoginRequest {
961            url: Some(server.uri()),
962            token: Some(SecretString::from(
963                "perm-YWNtZQ==.dGVzdA==.secret123456".to_string(),
964            )),
965            profile: Some("acme".into()),
966            ..LoginRequest::default()
967        };
968        let error = login(&le, request).await.unwrap_err();
969        assert!(error.to_string().contains("jane.admin"), "{error}");
970        assert_eq!(
971            f.store.load().unwrap().profiles["acme"].login.as_deref(),
972            Some("jane.admin")
973        );
974        let url = InstanceUrl::parse(&server.uri()).unwrap();
975        assert!(
976            credentials::load(&url.account("jane"))
977                .await
978                .unwrap()
979                .is_none()
980        );
981    }
982
983    #[tokio::test]
984    async fn a_requested_profile_of_another_instance_is_left_alone() {
985        let server = youtrack(jane()).await;
986        let f = fixture();
987        f.store
988            .set_profile("acme", &existing("https://other.youtrack.cloud", "jane"))
989            .unwrap();
990        let ui = Ui::new(false, false);
991        let le = LoginEnv {
992            env: &f.env,
993            ui: &ui,
994            store: &f.store,
995            http: &f.http,
996            open_browser: &no_browser,
997        };
998        let request = LoginRequest {
999            url: Some(server.uri()),
1000            token: Some(SecretString::from(
1001                "perm-b3RoZXI=.dGVzdA==.secret123456".to_string(),
1002            )),
1003            profile: Some("acme".into()),
1004            ..LoginRequest::default()
1005        };
1006        let error = login(&le, request).await.unwrap_err();
1007        assert!(
1008            error.to_string().contains("other.youtrack.cloud"),
1009            "{error}"
1010        );
1011        assert_eq!(
1012            f.store.load().unwrap().profiles["acme"].url,
1013            "https://other.youtrack.cloud"
1014        );
1015    }
1016}