1use std::collections::HashMap;
35use std::io::{Read, Write};
36use std::net::TcpListener;
37use std::path::PathBuf;
38use std::time::{Duration, SystemTime, UNIX_EPOCH};
39
40use serde::{Deserialize, Serialize};
41
42const AUTHORIZE_URL: &str = "https://auth.atlassian.com/authorize";
43const TOKEN_URL: &str = "https://auth.atlassian.com/oauth/token";
44const RESOURCES_URL: &str = "https://api.atlassian.com/oauth/token/accessible-resources";
45pub const API_BASE: &str = "https://api.atlassian.com/ex/jira";
47const DEFAULT_SCOPES: &str = "read:jira-work read:jira-user offline_access";
48const EXPIRY_SKEW_SECS: u64 = 60;
51const AUTH_REDIRECT_TIMEOUT_SECS: u64 = 300;
53
54fn now_secs() -> u64 {
55 SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .map_or(0, |d| d.as_secs())
58}
59
60#[derive(Debug, Clone)]
66pub struct OAuthApp {
67 pub client_id: String,
68 pub client_secret: String,
69 pub scopes: String,
70}
71
72impl OAuthApp {
73 pub fn from_env() -> Result<Self, String> {
76 let client_id = std::env::var("JIRA_OAUTH_CLIENT_ID")
77 .ok()
78 .filter(|v| !v.trim().is_empty())
79 .ok_or_else(|| {
80 "JIRA_OAUTH_CLIENT_ID not set. Register a free Atlassian OAuth 2.0 (3LO) app at \
81 https://developer.atlassian.com/console/myapps/ and export JIRA_OAUTH_CLIENT_ID \
82 and JIRA_OAUTH_CLIENT_SECRET."
83 .to_string()
84 })?;
85 let client_secret = std::env::var("JIRA_OAUTH_CLIENT_SECRET")
86 .ok()
87 .filter(|v| !v.trim().is_empty())
88 .ok_or_else(|| {
89 "JIRA_OAUTH_CLIENT_SECRET not set (from your Atlassian 3LO app).".to_string()
90 })?;
91 let scopes = std::env::var("JIRA_OAUTH_SCOPES")
92 .ok()
93 .map(|v| v.trim().to_string())
94 .filter(|v| !v.is_empty())
95 .unwrap_or_else(|| DEFAULT_SCOPES.to_string());
96 Ok(Self {
97 client_id: client_id.trim().to_string(),
98 client_secret: client_secret.trim().to_string(),
99 scopes,
100 })
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct StoredCredential {
111 pub access_token: String,
112 pub refresh_token: String,
113 pub expires_at: u64,
115 pub cloud_id: String,
117 pub cloud_url: String,
119 pub scopes: String,
120}
121
122impl StoredCredential {
123 pub fn needs_refresh(&self, now: u64) -> bool {
125 now.saturating_add(EXPIRY_SKEW_SECS) >= self.expires_at
126 }
127
128 pub fn api_base(&self) -> String {
130 format!("{API_BASE}/{}", self.cloud_id)
131 }
132}
133
134type Store = HashMap<String, StoredCredential>;
136
137fn credentials_path() -> Result<PathBuf, String> {
138 Ok(crate::core::paths::data_dir()?
141 .join("credentials")
142 .join("jira-oauth.json"))
143}
144
145fn load_store() -> Store {
146 let Ok(path) = credentials_path() else {
147 return Store::new();
148 };
149 let Ok(bytes) = std::fs::read(&path) else {
150 return Store::new();
151 };
152 serde_json::from_slice(&bytes).unwrap_or_default()
153}
154
155fn save_store(store: &Store) -> Result<(), String> {
156 let path = credentials_path()?;
157 if let Some(parent) = path.parent() {
158 std::fs::create_dir_all(parent)
159 .map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
160 }
161 let json = serde_json::to_vec_pretty(store).map_err(|e| format!("serialize error: {e}"))?;
162 let tmp = path.with_extension("json.tmp");
165 write_private(&tmp, &json)?;
166 std::fs::rename(&tmp, &path).map_err(|e| format!("cannot persist credentials: {e}"))?;
167 Ok(())
168}
169
170#[cfg(unix)]
171fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
172 use std::os::unix::fs::OpenOptionsExt;
173 let mut f = std::fs::OpenOptions::new()
174 .write(true)
175 .create(true)
176 .truncate(true)
177 .mode(0o600)
178 .open(path)
179 .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
180 f.write_all(bytes)
181 .map_err(|e| format!("cannot write {}: {e}", path.display()))?;
182 Ok(())
183}
184
185#[cfg(not(unix))]
186fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
187 std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
188}
189
190pub fn get_credential(data_source: &str) -> Option<StoredCredential> {
192 load_store().get(data_source).cloned()
193}
194
195pub fn put_credential(data_source: &str, cred: StoredCredential) -> Result<(), String> {
197 let mut store = load_store();
198 store.insert(data_source.to_string(), cred);
199 save_store(&store)
200}
201
202pub fn remove_credential(data_source: &str) -> Result<bool, String> {
204 let mut store = load_store();
205 let existed = store.remove(data_source).is_some();
206 save_store(&store)?;
207 Ok(existed)
208}
209
210pub fn list_connections() -> Vec<String> {
212 let mut keys: Vec<String> = load_store().into_keys().collect();
213 keys.sort();
214 keys
215}
216
217#[derive(Debug, Deserialize)]
222struct TokenResponse {
223 access_token: String,
224 expires_in: u64,
225 #[serde(default)]
226 refresh_token: Option<String>,
227 #[serde(default)]
228 scope: Option<String>,
229}
230
231#[derive(Debug, Clone, Deserialize)]
233pub struct CloudResource {
234 pub id: String,
235 #[serde(default)]
236 pub url: String,
237 #[serde(default)]
238 pub name: String,
239}
240
241pub fn authorize_url(app: &OAuthApp, redirect_uri: &str, state: &str) -> String {
247 format!(
248 "{AUTHORIZE_URL}?audience=api.atlassian.com&client_id={cid}&scope={scope}&redirect_uri={redirect}&state={state}&response_type=code&prompt=consent",
249 cid = urlencoding::encode(&app.client_id),
250 scope = urlencoding::encode(&app.scopes),
251 redirect = urlencoding::encode(redirect_uri),
252 state = urlencoding::encode(state),
253 )
254}
255
256fn form_encode(pairs: &[(&str, &str)]) -> Vec<u8> {
257 pairs
258 .iter()
259 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
260 .collect::<Vec<_>>()
261 .join("&")
262 .into_bytes()
263}
264
265fn post_token(body: &[u8]) -> Result<TokenResponse, String> {
270 let text = ureq::post(TOKEN_URL)
271 .header("Content-Type", "application/x-www-form-urlencoded")
272 .header("Accept", "application/json")
273 .send(body)
274 .map_err(|e| format!("Jira OAuth token request failed: {e}"))?
275 .into_body()
276 .read_to_string()
277 .map_err(|e| format!("Jira OAuth token read error: {e}"))?;
278 serde_json::from_str(&text).map_err(|e| format!("Jira OAuth token parse error: {e}"))
279}
280
281fn exchange_code(app: &OAuthApp, code: &str, redirect_uri: &str) -> Result<TokenResponse, String> {
282 let body = form_encode(&[
283 ("grant_type", "authorization_code"),
284 ("client_id", &app.client_id),
285 ("client_secret", &app.client_secret),
286 ("code", code),
287 ("redirect_uri", redirect_uri),
288 ]);
289 post_token(&body)
290}
291
292fn refresh_tokens(app: &OAuthApp, refresh_token: &str) -> Result<TokenResponse, String> {
293 let body = form_encode(&[
294 ("grant_type", "refresh_token"),
295 ("client_id", &app.client_id),
296 ("client_secret", &app.client_secret),
297 ("refresh_token", refresh_token),
298 ]);
299 post_token(&body)
300}
301
302pub fn accessible_resources(access_token: &str) -> Result<Vec<CloudResource>, String> {
304 let text = ureq::get(RESOURCES_URL)
305 .header("Authorization", &format!("Bearer {access_token}"))
306 .header("Accept", "application/json")
307 .call()
308 .map_err(|e| format!("Jira accessible-resources request failed: {e}"))?
309 .into_body()
310 .read_to_string()
311 .map_err(|e| format!("Jira accessible-resources read error: {e}"))?;
312 serde_json::from_str(&text).map_err(|e| format!("Jira accessible-resources parse error: {e}"))
313}
314
315#[derive(Debug, Clone)]
321pub struct ResolvedToken {
322 pub access_token: String,
323 pub cloud_id: String,
324 pub cloud_url: String,
325}
326
327pub fn ensure_valid_access_token(data_source: &str) -> Result<ResolvedToken, String> {
333 let cred = get_credential(data_source).ok_or_else(|| {
334 format!(
335 "Jira data source '{data_source}' is not connected. Run: lean-ctx provider auth jira \
336 --data-source {data_source}"
337 )
338 })?;
339
340 if !cred.needs_refresh(now_secs()) {
341 return Ok(ResolvedToken {
342 access_token: cred.access_token,
343 cloud_id: cred.cloud_id,
344 cloud_url: cred.cloud_url,
345 });
346 }
347
348 let app = OAuthApp::from_env().map_err(|e| {
350 format!("Jira access token for '{data_source}' expired and cannot refresh: {e}")
351 })?;
352
353 let tok = refresh_tokens(&app, &cred.refresh_token).map_err(|e| {
354 format!(
355 "Jira token refresh for '{data_source}' failed ({e}). The refresh token may be \
356 revoked or expired — reconnect with: lean-ctx provider auth jira --data-source {data_source}"
357 )
358 })?;
359
360 let new_refresh = tok.refresh_token.unwrap_or(cred.refresh_token);
362 let updated = StoredCredential {
363 access_token: tok.access_token.clone(),
364 refresh_token: new_refresh,
365 expires_at: now_secs().saturating_add(tok.expires_in),
366 cloud_id: cred.cloud_id.clone(),
367 cloud_url: cred.cloud_url.clone(),
368 scopes: tok.scope.unwrap_or(cred.scopes),
369 };
370 put_credential(data_source, updated.clone())?;
371
372 Ok(ResolvedToken {
373 access_token: updated.access_token,
374 cloud_id: updated.cloud_id,
375 cloud_url: updated.cloud_url,
376 })
377}
378
379fn random_state() -> String {
385 let mut buf = [0u8; 24];
386 if getrandom::fill(&mut buf).is_err() {
387 let n = now_secs();
391 for (i, b) in buf.iter_mut().enumerate() {
392 *b = ((n >> (i % 8)) as u8) ^ (i as u8).wrapping_mul(31);
393 }
394 }
395 use std::fmt::Write as _;
396 buf.iter()
397 .fold(String::with_capacity(buf.len() * 2), |mut s, b| {
398 let _ = write!(s, "{b:02x}");
399 s
400 })
401}
402
403fn open_in_browser(url: &str) {
404 #[cfg(target_os = "macos")]
405 let cmd = ("open", vec![url.to_string()]);
406 #[cfg(target_os = "windows")]
407 let cmd = (
408 "cmd",
409 vec![
410 "/C".to_string(),
411 "start".to_string(),
412 String::new(),
413 url.to_string(),
414 ],
415 );
416 #[cfg(all(unix, not(target_os = "macos")))]
417 let cmd = ("xdg-open", vec![url.to_string()]);
418
419 let _ = std::process::Command::new(cmd.0)
420 .args(cmd.1)
421 .stdout(std::process::Stdio::null())
422 .stderr(std::process::Stdio::null())
423 .spawn();
424}
425
426fn parse_callback(request_line: &str) -> Option<(String, String)> {
429 let path = request_line.split_whitespace().nth(1)?;
430 let query = path.split_once('?')?.1;
431 let mut code = None;
432 let mut state = None;
433 for pair in query.split('&') {
434 if let Some((k, v)) = pair.split_once('=') {
435 let decoded = urlencoding::decode(v)
436 .map(std::borrow::Cow::into_owned)
437 .ok()?;
438 match k {
439 "code" => code = Some(decoded),
440 "state" => state = Some(decoded),
441 _ => {}
442 }
443 }
444 }
445 Some((code?, state?))
446}
447
448fn await_redirect(listener: &TcpListener, timeout: Duration) -> Result<(String, String), String> {
449 listener
450 .set_nonblocking(false)
451 .map_err(|e| format!("listener error: {e}"))?;
452 let deadline = std::time::Instant::now() + timeout;
453 loop {
455 if std::time::Instant::now() >= deadline {
456 return Err("timed out waiting for the Atlassian redirect (5 min)".to_string());
457 }
458 let (mut stream, _) = listener
459 .accept()
460 .map_err(|e| format!("failed to accept redirect: {e}"))?;
461 stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
462 let mut buf = [0u8; 4096];
463 let n = stream.read(&mut buf).unwrap_or(0);
464 let request = String::from_utf8_lossy(&buf[..n]);
465 let first_line = request.lines().next().unwrap_or("");
466
467 if let Some((code, state)) = parse_callback(first_line) {
468 let html = "<html><body style=\"font-family:sans-serif\"><h2>lean-ctx connected to Jira ✓</h2><p>You can close this tab and return to your terminal.</p></body></html>";
469 let resp = format!(
470 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
471 html.len(),
472 html
473 );
474 let _ = stream.write_all(resp.as_bytes());
475 return Ok((code, state));
476 }
477 let _ = stream.write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n");
479 }
480}
481
482fn pick_resource(resources: Vec<CloudResource>) -> Result<CloudResource, String> {
483 match resources.len() {
484 0 => Err(
485 "no accessible Jira Cloud sites for this account — check the app scopes and that you \
486 selected a site during consent"
487 .to_string(),
488 ),
489 1 => Ok(resources.into_iter().next().unwrap()),
490 _ => {
491 println!("\nMultiple Jira sites are accessible — choose one:");
492 for (i, r) in resources.iter().enumerate() {
493 println!(" [{}] {} ({})", i + 1, r.url, r.name);
494 }
495 print!("Enter number: ");
496 let _ = std::io::stdout().flush();
497 let mut line = String::new();
498 std::io::stdin()
499 .read_line(&mut line)
500 .map_err(|e| format!("input error: {e}"))?;
501 let idx: usize = line
502 .trim()
503 .parse()
504 .map_err(|_| "invalid selection".to_string())?;
505 resources
506 .into_iter()
507 .nth(idx.saturating_sub(1))
508 .ok_or_else(|| "selection out of range".to_string())
509 }
510 }
511}
512
513pub fn run_auth_flow(data_source: &str) -> Result<(), String> {
516 let app = OAuthApp::from_env()?;
517
518 let listener = TcpListener::bind("127.0.0.1:0")
519 .map_err(|e| format!("cannot bind loopback redirect listener: {e}"))?;
520 let port = listener
521 .local_addr()
522 .map_err(|e| format!("cannot read local port: {e}"))?
523 .port();
524 let redirect_uri = format!("http://localhost:{port}/callback");
525
526 let state = random_state();
527 let url = authorize_url(&app, &redirect_uri, &state);
528
529 println!(
530 "\nlean-ctx needs your consent to read Jira on your behalf.\n\
531 Add this exact redirect URL to your Atlassian app's \"Callback URL\" list first:\n {redirect_uri}\n\n\
532 Then open this URL to authorize (it should open automatically):\n {url}\n"
533 );
534 open_in_browser(&url);
535
536 let (code, recv_state) =
537 await_redirect(&listener, Duration::from_secs(AUTH_REDIRECT_TIMEOUT_SECS))?;
538 if recv_state != state {
539 return Err("state mismatch on redirect (possible CSRF) — aborting".to_string());
540 }
541
542 let tok = exchange_code(&app, &code, &redirect_uri)?;
543 let resources = accessible_resources(&tok.access_token)?;
544 let resource = pick_resource(resources)?;
545
546 let cred = StoredCredential {
547 access_token: tok.access_token,
548 refresh_token: tok
549 .refresh_token
550 .ok_or("Atlassian did not return a refresh token — ensure the 'offline_access' scope is granted")?,
551 expires_at: now_secs().saturating_add(tok.expires_in),
552 cloud_id: resource.id,
553 cloud_url: resource.url.clone(),
554 scopes: tok.scope.unwrap_or(app.scopes),
555 };
556 put_credential(data_source, cred)?;
557
558 println!(
559 "✓ Connected Jira Cloud site {} as data source '{data_source}'.\n Tokens stored in {}",
560 resource.url,
561 credentials_path()
562 .map(|p| p.display().to_string())
563 .unwrap_or_default()
564 );
565 Ok(())
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 fn app() -> OAuthApp {
573 OAuthApp {
574 client_id: "abc 123".to_string(),
575 client_secret: "secret".to_string(),
576 scopes: "read:jira-work offline_access".to_string(),
577 }
578 }
579
580 #[test]
581 fn authorize_url_encodes_all_params() {
582 let url = authorize_url(&app(), "http://localhost:5000/callback", "st/ate+1");
583 assert!(url.starts_with("https://auth.atlassian.com/authorize?"));
584 assert!(url.contains("audience=api.atlassian.com"));
585 assert!(url.contains("response_type=code"));
586 assert!(url.contains("prompt=consent"));
587 assert!(url.contains("client_id=abc%20123"));
588 assert!(url.contains("scope=read%3Ajira-work%20offline_access"));
589 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback"));
590 assert!(url.contains("state=st%2Fate%2B1"));
591 }
592
593 #[test]
594 fn parse_callback_extracts_code_and_state() {
595 let line = "GET /callback?code=AUTH%2FCODE&state=xyz HTTP/1.1";
596 let (code, state) = parse_callback(line).unwrap();
597 assert_eq!(code, "AUTH/CODE");
598 assert_eq!(state, "xyz");
599 }
600
601 #[test]
602 fn parse_callback_handles_missing_params() {
603 assert!(parse_callback("GET /callback?code=only HTTP/1.1").is_none());
604 assert!(parse_callback("GET /favicon.ico HTTP/1.1").is_none());
605 }
606
607 #[test]
608 fn needs_refresh_respects_skew() {
609 let now = 1_000_000;
610 let mut cred = StoredCredential {
611 access_token: "a".into(),
612 refresh_token: "r".into(),
613 expires_at: now + EXPIRY_SKEW_SECS + 10,
614 cloud_id: "cid".into(),
615 cloud_url: "https://x.atlassian.net".into(),
616 scopes: DEFAULT_SCOPES.into(),
617 };
618 assert!(!cred.needs_refresh(now), "valid token must not refresh");
619 cred.expires_at = now + EXPIRY_SKEW_SECS - 1;
620 assert!(cred.needs_refresh(now), "near-expiry token must refresh");
621 cred.expires_at = now - 1;
622 assert!(cred.needs_refresh(now), "expired token must refresh");
623 }
624
625 #[test]
626 fn api_base_includes_cloud_id() {
627 let cred = StoredCredential {
628 access_token: "a".into(),
629 refresh_token: "r".into(),
630 expires_at: 0,
631 cloud_id: "11aa-22bb".into(),
632 cloud_url: "https://x.atlassian.net".into(),
633 scopes: DEFAULT_SCOPES.into(),
634 };
635 assert_eq!(
636 cred.api_base(),
637 "https://api.atlassian.com/ex/jira/11aa-22bb"
638 );
639 }
640
641 #[test]
642 fn form_encode_escapes_values() {
643 let body = form_encode(&[("grant_type", "authorization_code"), ("code", "a/b c")]);
644 let s = String::from_utf8(body).unwrap();
645 assert_eq!(s, "grant_type=authorization_code&code=a%2Fb%20c");
646 }
647
648 #[test]
649 fn pick_resource_auto_selects_single() {
650 let r = pick_resource(vec![CloudResource {
651 id: "cid".into(),
652 url: "https://only.atlassian.net".into(),
653 name: "Only".into(),
654 }])
655 .unwrap();
656 assert_eq!(r.id, "cid");
657 }
658
659 #[test]
660 fn pick_resource_errors_on_empty() {
661 assert!(pick_resource(vec![]).is_err());
662 }
663
664 #[test]
665 fn random_state_is_unique_and_hex() {
666 let a = random_state();
667 let b = random_state();
668 assert_eq!(a.len(), 48, "24 bytes -> 48 hex chars");
669 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
670 assert_ne!(a, b, "state tokens must differ");
671 }
672}