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