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    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    // Write atomically with restrictive permissions so tokens are not
164    // world-readable. The temp file is created with 0600 up front on Unix.
165    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
191/// Returns the stored credential for `data_source`, if any.
192pub fn get_credential(data_source: &str) -> Option<StoredCredential> {
193    load_store().get(data_source).cloned()
194}
195
196/// Persists (or replaces) the credential for `data_source`.
197pub 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
203/// Removes the credential for `data_source`. Returns true if one existed.
204pub 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
211/// Lists the data-source ids that currently have a stored credential.
212pub fn list_connections() -> Vec<String> {
213    let mut keys: Vec<String> = load_store().into_keys().collect();
214    keys.sort();
215    keys
216}
217
218// ---------------------------------------------------------------------------
219// Token endpoint payloads
220// ---------------------------------------------------------------------------
221
222#[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/// One Atlassian cloud site the consenting user can access.
233#[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
242// ---------------------------------------------------------------------------
243// Pure URL/body builders (unit-tested)
244// ---------------------------------------------------------------------------
245
246/// Builds the Atlassian consent URL for the authorization-code flow.
247pub 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
266// ---------------------------------------------------------------------------
267// HTTP calls
268// ---------------------------------------------------------------------------
269
270fn 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
303/// Fetches the cloud sites the consenting user can access.
304pub 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// ---------------------------------------------------------------------------
317// Resolver used by the provider on every API call
318// ---------------------------------------------------------------------------
319
320/// A ready-to-use bearer token plus the cloud routing info for a data-source.
321#[derive(Debug, Clone)]
322pub struct ResolvedToken {
323    pub access_token: String,
324    pub cloud_id: String,
325    pub cloud_url: String,
326}
327
328/// Returns a valid access token for `data_source`, refreshing (and persisting
329/// the rotated refresh token) if the stored token is expired.
330///
331/// Errors clearly instruct the user to (re)connect when no credential exists or
332/// the refresh token is no longer valid.
333pub 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    // Expired: refresh requires the app credentials.
350    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    // Atlassian rotates refresh tokens: keep the new one if returned, else reuse.
362    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
380// ---------------------------------------------------------------------------
381// Interactive authorization-code flow (CLI)
382// ---------------------------------------------------------------------------
383
384/// Generates a cryptographically-random URL-safe state token for CSRF defense.
385fn random_state() -> String {
386    let mut buf = [0u8; 24];
387    if getrandom::fill(&mut buf).is_err() {
388        // Extremely unlikely; fall back to a time-derived value. Still unguessable
389        // enough for a single short-lived loopback exchange, and the redirect is
390        // bound to a freshly-bound local port.
391        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
427/// Parses `code` and `state` from a raw HTTP request line like
428/// `GET /callback?code=XXX&state=YYY HTTP/1.1`.
429fn 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    // A single browser redirect; loop only to skip favicon/preflight noise.
455    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        // Not the callback (e.g. favicon) — respond 204 and keep waiting.
479        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
514/// Runs the full interactive OAuth 2.0 3LO authorization-code flow and stores
515/// the resulting credential under `data_source`.
516pub 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}