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