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