Skip to main content

lean_ctx/core/providers/
jira_oauth.rs

1//! Jira Cloud OAuth 2.0 (3LO) client.
2//!
3//! Atlassian's 3LO is a *confidential* client flow: the token exchange requires a
4//! `client_id` **and** `client_secret`. lean-ctx ships no hosted backend and
5//! embeds no secrets, so each user registers their own free Atlassian OAuth 2.0
6//! (3LO) app (developer.atlassian.com → "OAuth 2.0 integration") and points
7//! lean-ctx at it via environment variables:
8//!
9//!   - `JIRA_OAUTH_CLIENT_ID`     — the app's client id
10//!   - `JIRA_OAUTH_CLIENT_SECRET` — the app's client secret
11//!   - `JIRA_OAUTH_SCOPES`        — optional, space-separated; defaults below
12//!
13//! Run once to grant consent:
14//!
15//! ```text
16//! lean-ctx provider auth jira [--data-source <id>]
17//! ```
18//!
19//! Tokens are stored in `~/.lean-ctx/credentials/jira-oauth.json` (file mode
20//! `0600`), keyed by data-source id so multiple Jira tenants / custom Jira data
21//! sources can coexist. Access tokens are refreshed automatically using
22//! Atlassian's **rotating** refresh-token flow: every refresh response that
23//! carries a new refresh token replaces the stored one. When the refresh token
24//! is itself revoked or expired, callers receive a clear "reconnect" error.
25//!
26//! ## Minimal scopes
27//!
28//! - `read:jira-work` — read issues, projects, boards, and sprints
29//! - `read:jira-user` — resolve reporter / assignee display names
30//! - `offline_access` — receive a refresh token for unattended refresh
31//!
32//! Add more (e.g. `write:jira-work`) only if a future action needs them.
33
34use 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";
45/// Per-cloud API prefix; the full base is `{API_BASE}/{cloud_id}`.
46pub const API_BASE: &str = "https://api.atlassian.com/ex/jira";
47const DEFAULT_SCOPES: &str = "read:jira-work read:jira-user offline_access";
48/// Refresh this many seconds *before* the real expiry to absorb clock skew and
49/// in-flight request latency.
50const EXPIRY_SKEW_SECS: u64 = 60;
51/// How long the loopback listener waits for the browser redirect before aborting.
52const 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// ---------------------------------------------------------------------------
61// App configuration (the user's own Atlassian 3LO app)
62// ---------------------------------------------------------------------------
63
64/// The user-registered Atlassian OAuth 2.0 (3LO) application credentials.
65#[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    /// Reads the app credentials from the environment. Returns a descriptive
74    /// error (with setup guidance) when they are missing.
75    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// ---------------------------------------------------------------------------
105// Stored credentials (per data-source)
106// ---------------------------------------------------------------------------
107
108/// A persisted Jira OAuth credential for one data-source id.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct StoredCredential {
111    pub access_token: String,
112    pub refresh_token: String,
113    /// Unix seconds at which `access_token` expires.
114    pub expires_at: u64,
115    /// Atlassian cloud id, used in `https://api.atlassian.com/ex/jira/{cloud_id}`.
116    pub cloud_id: String,
117    /// The site URL (e.g. `https://your-site.atlassian.net`) for `/browse` links.
118    pub cloud_url: String,
119    pub scopes: String,
120}
121
122impl StoredCredential {
123    /// True if the access token is expired or within the skew window.
124    pub fn needs_refresh(&self, now: u64) -> bool {
125        now.saturating_add(EXPIRY_SKEW_SECS) >= self.expires_at
126    }
127
128    /// The per-cloud Jira API base URL for this credential.
129    pub fn api_base(&self) -> String {
130        format!("{API_BASE}/{}", self.cloud_id)
131    }
132}
133
134/// The on-disk credential store: `{ data_source_id -> StoredCredential }`.
135type Store = HashMap<String, StoredCredential>;
136
137fn credentials_path() -> Result<PathBuf, String> {
138    // GH #439: store under the typed data resolver (doctor --fix categorizes
139    // `credentials/` as data) so a split install doesn't re-create ~/.lean-ctx.
140    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    // Write atomically with restrictive permissions so tokens are not
163    // world-readable. The temp file is created with 0600 up front on Unix.
164    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
190/// Returns the stored credential for `data_source`, if any.
191pub fn get_credential(data_source: &str) -> Option<StoredCredential> {
192    load_store().get(data_source).cloned()
193}
194
195/// Persists (or replaces) the credential for `data_source`.
196pub 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
202/// Removes the credential for `data_source`. Returns true if one existed.
203pub 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
210/// Lists the data-source ids that currently have a stored credential.
211pub fn list_connections() -> Vec<String> {
212    let mut keys: Vec<String> = load_store().into_keys().collect();
213    keys.sort();
214    keys
215}
216
217// ---------------------------------------------------------------------------
218// Token endpoint payloads
219// ---------------------------------------------------------------------------
220
221#[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/// One Atlassian cloud site the consenting user can access.
232#[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
241// ---------------------------------------------------------------------------
242// Pure URL/body builders (unit-tested)
243// ---------------------------------------------------------------------------
244
245/// Builds the Atlassian consent URL for the authorization-code flow.
246pub 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
265// ---------------------------------------------------------------------------
266// HTTP calls
267// ---------------------------------------------------------------------------
268
269fn 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
302/// Fetches the cloud sites the consenting user can access.
303pub 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// ---------------------------------------------------------------------------
316// Resolver used by the provider on every API call
317// ---------------------------------------------------------------------------
318
319/// A ready-to-use bearer token plus the cloud routing info for a data-source.
320#[derive(Debug, Clone)]
321pub struct ResolvedToken {
322    pub access_token: String,
323    pub cloud_id: String,
324    pub cloud_url: String,
325}
326
327/// Returns a valid access token for `data_source`, refreshing (and persisting
328/// the rotated refresh token) if the stored token is expired.
329///
330/// Errors clearly instruct the user to (re)connect when no credential exists or
331/// the refresh token is no longer valid.
332pub 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    // Expired: refresh requires the app credentials.
349    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    // Atlassian rotates refresh tokens: keep the new one if returned, else reuse.
361    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
379// ---------------------------------------------------------------------------
380// Interactive authorization-code flow (CLI)
381// ---------------------------------------------------------------------------
382
383/// Generates a cryptographically-random URL-safe state token for CSRF defense.
384fn random_state() -> String {
385    let mut buf = [0u8; 24];
386    if getrandom::fill(&mut buf).is_err() {
387        // Extremely unlikely; fall back to a time-derived value. Still unguessable
388        // enough for a single short-lived loopback exchange, and the redirect is
389        // bound to a freshly-bound local port.
390        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
426/// Parses `code` and `state` from a raw HTTP request line like
427/// `GET /callback?code=XXX&state=YYY HTTP/1.1`.
428fn 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    // A single browser redirect; loop only to skip favicon/preflight noise.
454    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        // Not the callback (e.g. favicon) — respond 204 and keep waiting.
478        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
513/// Runs the full interactive OAuth 2.0 3LO authorization-code flow and stores
514/// the resulting credential under `data_source`.
515pub 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}