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