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 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
204async 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
237fn 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
434fn 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
486async 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 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 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 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}