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
21const 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
63pub 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
199async 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
425fn 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
477async 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 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}