use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const AUTHORIZE_URL: &str = "https://auth.atlassian.com/authorize";
const TOKEN_URL: &str = "https://auth.atlassian.com/oauth/token";
const RESOURCES_URL: &str = "https://api.atlassian.com/oauth/token/accessible-resources";
pub const API_BASE: &str = "https://api.atlassian.com/ex/jira";
const DEFAULT_SCOPES: &str = "read:jira-work read:jira-user offline_access";
const EXPIRY_SKEW_SECS: u64 = 60;
const AUTH_REDIRECT_TIMEOUT_SECS: u64 = 300;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[derive(Debug, Clone)]
pub struct OAuthApp {
pub client_id: String,
pub client_secret: String,
pub scopes: String,
}
impl OAuthApp {
pub fn from_env() -> Result<Self, String> {
let client_id = std::env::var("JIRA_OAUTH_CLIENT_ID")
.ok()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| {
"JIRA_OAUTH_CLIENT_ID not set. Register a free Atlassian OAuth 2.0 (3LO) app at \
https://developer.atlassian.com/console/myapps/ and export JIRA_OAUTH_CLIENT_ID \
and JIRA_OAUTH_CLIENT_SECRET."
.to_string()
})?;
let client_secret = std::env::var("JIRA_OAUTH_CLIENT_SECRET")
.ok()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| {
"JIRA_OAUTH_CLIENT_SECRET not set (from your Atlassian 3LO app).".to_string()
})?;
let scopes = std::env::var("JIRA_OAUTH_SCOPES")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_SCOPES.to_string());
Ok(Self {
client_id: client_id.trim().to_string(),
client_secret: client_secret.trim().to_string(),
scopes,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredCredential {
pub access_token: String,
pub refresh_token: String,
pub expires_at: u64,
pub cloud_id: String,
pub cloud_url: String,
pub scopes: String,
}
impl StoredCredential {
pub fn needs_refresh(&self, now: u64) -> bool {
now.saturating_add(EXPIRY_SKEW_SECS) >= self.expires_at
}
pub fn api_base(&self) -> String {
format!("{API_BASE}/{}", self.cloud_id)
}
}
type Store = HashMap<String, StoredCredential>;
fn credentials_path() -> Result<PathBuf, String> {
let home = crate::core::home::resolve_home_dir()
.ok_or_else(|| "cannot resolve home directory for credential storage".to_string())?;
Ok(home
.join(".lean-ctx")
.join("credentials")
.join("jira-oauth.json"))
}
fn load_store() -> Store {
let Ok(path) = credentials_path() else {
return Store::new();
};
let Ok(bytes) = std::fs::read(&path) else {
return Store::new();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
fn save_store(store: &Store) -> Result<(), String> {
let path = credentials_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let json = serde_json::to_vec_pretty(store).map_err(|e| format!("serialize error: {e}"))?;
let tmp = path.with_extension("json.tmp");
write_private(&tmp, &json)?;
std::fs::rename(&tmp, &path).map_err(|e| format!("cannot persist credentials: {e}"))?;
Ok(())
}
#[cfg(unix)]
fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
f.write_all(bytes)
.map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
}
pub fn get_credential(data_source: &str) -> Option<StoredCredential> {
load_store().get(data_source).cloned()
}
pub fn put_credential(data_source: &str, cred: StoredCredential) -> Result<(), String> {
let mut store = load_store();
store.insert(data_source.to_string(), cred);
save_store(&store)
}
pub fn remove_credential(data_source: &str) -> Result<bool, String> {
let mut store = load_store();
let existed = store.remove(data_source).is_some();
save_store(&store)?;
Ok(existed)
}
pub fn list_connections() -> Vec<String> {
let mut keys: Vec<String> = load_store().into_keys().collect();
keys.sort();
keys
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CloudResource {
pub id: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub name: String,
}
pub fn authorize_url(app: &OAuthApp, redirect_uri: &str, state: &str) -> String {
format!(
"{AUTHORIZE_URL}?audience=api.atlassian.com&client_id={cid}&scope={scope}&redirect_uri={redirect}&state={state}&response_type=code&prompt=consent",
cid = urlencoding::encode(&app.client_id),
scope = urlencoding::encode(&app.scopes),
redirect = urlencoding::encode(redirect_uri),
state = urlencoding::encode(state),
)
}
fn form_encode(pairs: &[(&str, &str)]) -> Vec<u8> {
pairs
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&")
.into_bytes()
}
fn post_token(body: &[u8]) -> Result<TokenResponse, String> {
let text = ureq::post(TOKEN_URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.send(body)
.map_err(|e| format!("Jira OAuth token request failed: {e}"))?
.into_body()
.read_to_string()
.map_err(|e| format!("Jira OAuth token read error: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("Jira OAuth token parse error: {e}"))
}
fn exchange_code(app: &OAuthApp, code: &str, redirect_uri: &str) -> Result<TokenResponse, String> {
let body = form_encode(&[
("grant_type", "authorization_code"),
("client_id", &app.client_id),
("client_secret", &app.client_secret),
("code", code),
("redirect_uri", redirect_uri),
]);
post_token(&body)
}
fn refresh_tokens(app: &OAuthApp, refresh_token: &str) -> Result<TokenResponse, String> {
let body = form_encode(&[
("grant_type", "refresh_token"),
("client_id", &app.client_id),
("client_secret", &app.client_secret),
("refresh_token", refresh_token),
]);
post_token(&body)
}
pub fn accessible_resources(access_token: &str) -> Result<Vec<CloudResource>, String> {
let text = ureq::get(RESOURCES_URL)
.header("Authorization", &format!("Bearer {access_token}"))
.header("Accept", "application/json")
.call()
.map_err(|e| format!("Jira accessible-resources request failed: {e}"))?
.into_body()
.read_to_string()
.map_err(|e| format!("Jira accessible-resources read error: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("Jira accessible-resources parse error: {e}"))
}
#[derive(Debug, Clone)]
pub struct ResolvedToken {
pub access_token: String,
pub cloud_id: String,
pub cloud_url: String,
}
pub fn ensure_valid_access_token(data_source: &str) -> Result<ResolvedToken, String> {
let cred = get_credential(data_source).ok_or_else(|| {
format!(
"Jira data source '{data_source}' is not connected. Run: lean-ctx provider auth jira \
--data-source {data_source}"
)
})?;
if !cred.needs_refresh(now_secs()) {
return Ok(ResolvedToken {
access_token: cred.access_token,
cloud_id: cred.cloud_id,
cloud_url: cred.cloud_url,
});
}
let app = OAuthApp::from_env().map_err(|e| {
format!("Jira access token for '{data_source}' expired and cannot refresh: {e}")
})?;
let tok = refresh_tokens(&app, &cred.refresh_token).map_err(|e| {
format!(
"Jira token refresh for '{data_source}' failed ({e}). The refresh token may be \
revoked or expired — reconnect with: lean-ctx provider auth jira --data-source {data_source}"
)
})?;
let new_refresh = tok.refresh_token.unwrap_or(cred.refresh_token);
let updated = StoredCredential {
access_token: tok.access_token.clone(),
refresh_token: new_refresh,
expires_at: now_secs().saturating_add(tok.expires_in),
cloud_id: cred.cloud_id.clone(),
cloud_url: cred.cloud_url.clone(),
scopes: tok.scope.unwrap_or(cred.scopes),
};
put_credential(data_source, updated.clone())?;
Ok(ResolvedToken {
access_token: updated.access_token,
cloud_id: updated.cloud_id,
cloud_url: updated.cloud_url,
})
}
fn random_state() -> String {
let mut buf = [0u8; 24];
if getrandom::fill(&mut buf).is_err() {
let n = now_secs();
for (i, b) in buf.iter_mut().enumerate() {
*b = ((n >> (i % 8)) as u8) ^ (i as u8).wrapping_mul(31);
}
}
use std::fmt::Write as _;
buf.iter()
.fold(String::with_capacity(buf.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let cmd = ("open", vec![url.to_string()]);
#[cfg(target_os = "windows")]
let cmd = (
"cmd",
vec![
"/C".to_string(),
"start".to_string(),
String::new(),
url.to_string(),
],
);
#[cfg(all(unix, not(target_os = "macos")))]
let cmd = ("xdg-open", vec![url.to_string()]);
let _ = std::process::Command::new(cmd.0)
.args(cmd.1)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
fn parse_callback(request_line: &str) -> Option<(String, String)> {
let path = request_line.split_whitespace().nth(1)?;
let query = path.split_once('?')?.1;
let mut code = None;
let mut state = None;
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
let decoded = urlencoding::decode(v)
.map(std::borrow::Cow::into_owned)
.ok()?;
match k {
"code" => code = Some(decoded),
"state" => state = Some(decoded),
_ => {}
}
}
}
Some((code?, state?))
}
fn await_redirect(listener: &TcpListener, timeout: Duration) -> Result<(String, String), String> {
listener
.set_nonblocking(false)
.map_err(|e| format!("listener error: {e}"))?;
let deadline = std::time::Instant::now() + timeout;
loop {
if std::time::Instant::now() >= deadline {
return Err("timed out waiting for the Atlassian redirect (5 min)".to_string());
}
let (mut stream, _) = listener
.accept()
.map_err(|e| format!("failed to accept redirect: {e}"))?;
stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
let first_line = request.lines().next().unwrap_or("");
if let Some((code, state)) = parse_callback(first_line) {
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>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
let _ = stream.write_all(resp.as_bytes());
return Ok((code, state));
}
let _ = stream.write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n");
}
}
fn pick_resource(resources: Vec<CloudResource>) -> Result<CloudResource, String> {
match resources.len() {
0 => Err(
"no accessible Jira Cloud sites for this account — check the app scopes and that you \
selected a site during consent"
.to_string(),
),
1 => Ok(resources.into_iter().next().unwrap()),
_ => {
println!("\nMultiple Jira sites are accessible — choose one:");
for (i, r) in resources.iter().enumerate() {
println!(" [{}] {} ({})", i + 1, r.url, r.name);
}
print!("Enter number: ");
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| format!("input error: {e}"))?;
let idx: usize = line
.trim()
.parse()
.map_err(|_| "invalid selection".to_string())?;
resources
.into_iter()
.nth(idx.saturating_sub(1))
.ok_or_else(|| "selection out of range".to_string())
}
}
}
pub fn run_auth_flow(data_source: &str) -> Result<(), String> {
let app = OAuthApp::from_env()?;
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("cannot bind loopback redirect listener: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("cannot read local port: {e}"))?
.port();
let redirect_uri = format!("http://localhost:{port}/callback");
let state = random_state();
let url = authorize_url(&app, &redirect_uri, &state);
println!(
"\nlean-ctx needs your consent to read Jira on your behalf.\n\
Add this exact redirect URL to your Atlassian app's \"Callback URL\" list first:\n {redirect_uri}\n\n\
Then open this URL to authorize (it should open automatically):\n {url}\n"
);
open_in_browser(&url);
let (code, recv_state) =
await_redirect(&listener, Duration::from_secs(AUTH_REDIRECT_TIMEOUT_SECS))?;
if recv_state != state {
return Err("state mismatch on redirect (possible CSRF) — aborting".to_string());
}
let tok = exchange_code(&app, &code, &redirect_uri)?;
let resources = accessible_resources(&tok.access_token)?;
let resource = pick_resource(resources)?;
let cred = StoredCredential {
access_token: tok.access_token,
refresh_token: tok
.refresh_token
.ok_or("Atlassian did not return a refresh token — ensure the 'offline_access' scope is granted")?,
expires_at: now_secs().saturating_add(tok.expires_in),
cloud_id: resource.id,
cloud_url: resource.url.clone(),
scopes: tok.scope.unwrap_or(app.scopes),
};
put_credential(data_source, cred)?;
println!(
"✓ Connected Jira Cloud site {} as data source '{data_source}'.\n Tokens stored in {}",
resource.url,
credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_default()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> OAuthApp {
OAuthApp {
client_id: "abc 123".to_string(),
client_secret: "secret".to_string(),
scopes: "read:jira-work offline_access".to_string(),
}
}
#[test]
fn authorize_url_encodes_all_params() {
let url = authorize_url(&app(), "http://localhost:5000/callback", "st/ate+1");
assert!(url.starts_with("https://auth.atlassian.com/authorize?"));
assert!(url.contains("audience=api.atlassian.com"));
assert!(url.contains("response_type=code"));
assert!(url.contains("prompt=consent"));
assert!(url.contains("client_id=abc%20123"));
assert!(url.contains("scope=read%3Ajira-work%20offline_access"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback"));
assert!(url.contains("state=st%2Fate%2B1"));
}
#[test]
fn parse_callback_extracts_code_and_state() {
let line = "GET /callback?code=AUTH%2FCODE&state=xyz HTTP/1.1";
let (code, state) = parse_callback(line).unwrap();
assert_eq!(code, "AUTH/CODE");
assert_eq!(state, "xyz");
}
#[test]
fn parse_callback_handles_missing_params() {
assert!(parse_callback("GET /callback?code=only HTTP/1.1").is_none());
assert!(parse_callback("GET /favicon.ico HTTP/1.1").is_none());
}
#[test]
fn needs_refresh_respects_skew() {
let now = 1_000_000;
let mut cred = StoredCredential {
access_token: "a".into(),
refresh_token: "r".into(),
expires_at: now + EXPIRY_SKEW_SECS + 10,
cloud_id: "cid".into(),
cloud_url: "https://x.atlassian.net".into(),
scopes: DEFAULT_SCOPES.into(),
};
assert!(!cred.needs_refresh(now), "valid token must not refresh");
cred.expires_at = now + EXPIRY_SKEW_SECS - 1;
assert!(cred.needs_refresh(now), "near-expiry token must refresh");
cred.expires_at = now - 1;
assert!(cred.needs_refresh(now), "expired token must refresh");
}
#[test]
fn api_base_includes_cloud_id() {
let cred = StoredCredential {
access_token: "a".into(),
refresh_token: "r".into(),
expires_at: 0,
cloud_id: "11aa-22bb".into(),
cloud_url: "https://x.atlassian.net".into(),
scopes: DEFAULT_SCOPES.into(),
};
assert_eq!(
cred.api_base(),
"https://api.atlassian.com/ex/jira/11aa-22bb"
);
}
#[test]
fn form_encode_escapes_values() {
let body = form_encode(&[("grant_type", "authorization_code"), ("code", "a/b c")]);
let s = String::from_utf8(body).unwrap();
assert_eq!(s, "grant_type=authorization_code&code=a%2Fb%20c");
}
#[test]
fn pick_resource_auto_selects_single() {
let r = pick_resource(vec![CloudResource {
id: "cid".into(),
url: "https://only.atlassian.net".into(),
name: "Only".into(),
}])
.unwrap();
assert_eq!(r.id, "cid");
}
#[test]
fn pick_resource_errors_on_empty() {
assert!(pick_resource(vec![]).is_err());
}
#[test]
fn random_state_is_unique_and_hex() {
let a = random_state();
let b = random_state();
assert_eq!(a.len(), 48, "24 bytes -> 48 hex chars");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b, "state tokens must differ");
}
}