Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-client/src/lib.rs

//! # nornir-auth-client
//!
//! The **shared client-side login** for the nordisk apps — the missing half of
//! the auth stack. The server side (verify a presented bearer) lives in
//! `nornir-service` (the [`AuthProvider`] seam) + the provider crates
//! (`nornir-auth-oidc`, `nornir-auth-bearer`); this crate is what a *client* CLI
//! calls to **obtain** a token in the first place and keep it fresh, so
//! `<app> login` works identically in korp, holger, and dwarves instead of each
//! hand-rolling a device-code loop and a token file.
//!
//! Three pieces:
//!
//! * [`TokenBundle`] — the cached credential: an access token, an optional
//!   refresh token, and the access-token expiry.
//! * [`TokenStore`] — the object-safe **storage seam**. The default
//!   [`FileTokenStore`] is a 0600 JSON file under `~/.<app>/`; the SAME trait is
//!   what the dwarves encrypted **Vault** plugs into (a thin `impl TokenStore`
//!   that `set`/`get`s the bundle as a named secret — see the crate README), so a
//!   token can live encrypted-at-rest with the rest of a user's credentials.
//! * [`login`] / [`current_token`] (feature `online`) — run the OIDC
//!   device-authorization grant (RFC 8628, reusing
//!   [`nornir_auth_oidc::device`]) to acquire a token, then hand callers a valid
//!   access token on demand, **silently refreshing** an expired one when a
//!   refresh token is held. Offline builds still get [`cached_token`] (read a
//!   non-expired cached token; no network).
//!
//! [`AuthProvider`]: nornir_auth_oidc::AuthProvider

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

pub use nornir_auth_oidc::device::{self, DeviceCode, TokenResponse};

/// Clock skew (seconds) subtracted from a token's expiry so we treat it as stale
/// slightly early — a token about to expire mid-request is refreshed first.
const DEFAULT_SKEW_SECS: i64 = 30;

/// Current unix time in seconds.
fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

// ─── The cached credential ───────────────────────────────────────────────────

/// A stored login: the access token plus what is needed to renew it. Persisted by
/// a [`TokenStore`]. Contains a secret (the tokens) — a file store MUST be 0600.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenBundle {
    /// The OAuth2/OIDC access token presented as `Bearer <access_token>`.
    pub access_token: String,
    /// The refresh token, when the IdP issued one (device-code with `offline_access`).
    #[serde(default)]
    pub refresh_token: Option<String>,
    /// Unix time (seconds) the access token expires, when known. `None` ⇒ lifetime
    /// unknown; treated as valid until a call rejects it (the caller re-logins).
    #[serde(default)]
    pub expires_at_unix: Option<i64>,
    /// The authenticated subject, if the login surfaced one (informational).
    #[serde(default)]
    pub subject: Option<String>,
}

impl TokenBundle {
    /// Build from a token-endpoint response, stamping the expiry from `expires_in`
    /// relative to `now`. A response without a refresh token keeps `prev_refresh`
    /// (refresh grants often omit a rotated token — don't drop the one we have).
    pub fn from_response(tr: TokenResponse, now: i64, prev_refresh: Option<String>) -> Self {
        TokenBundle {
            expires_at_unix: tr.expires_in.map(|s| now + s),
            refresh_token: tr.refresh_token.or(prev_refresh),
            subject: None,
            access_token: tr.access_token,
        }
    }

    /// Is the access token still usable at `now` (with `skew` seconds of margin)?
    /// An unknown expiry (`None`) is treated as valid.
    pub fn valid_at(&self, now: i64, skew: i64) -> bool {
        match self.expires_at_unix {
            None => true,
            Some(exp) => now + skew < exp,
        }
    }

    /// Is the access token usable now (default skew)?
    pub fn is_fresh(&self) -> bool {
        self.valid_at(now_unix(), DEFAULT_SKEW_SECS)
    }
}

// ─── The storage seam ────────────────────────────────────────────────────────

/// Where a [`TokenBundle`] is persisted between invocations — the object-safe
/// seam (`Box<dyn TokenStore>` / `&dyn TokenStore`). The shipped default is
/// [`FileTokenStore`]; the dwarves Vault plugs in as an alternative backend by
/// implementing this trait over `Vault::get`/`set`/`remove` (deferred wiring).
pub trait TokenStore {
    /// Load the stored bundle, or `Ok(None)` when nothing is stored yet.
    fn load(&self) -> anyhow::Result<Option<TokenBundle>>;
    /// Store (overwrite) the bundle.
    fn save(&self, bundle: &TokenBundle) -> anyhow::Result<()>;
    /// Remove any stored bundle (`<app> logout`). Absent ⇒ still `Ok`.
    fn clear(&self) -> anyhow::Result<()>;
}

/// The default [`TokenStore`]: a single 0600 JSON file (the same home + owner-only
/// convention as `nornir_service::resolve_token`'s token file).
pub struct FileTokenStore {
    path: PathBuf,
}

impl FileTokenStore {
    /// A store at an explicit path.
    pub fn at(path: impl Into<PathBuf>) -> Self {
        FileTokenStore { path: path.into() }
    }

    /// The conventional per-app store: `$HOME/.<app>/auth-token.json`.
    pub fn for_app(app: &str) -> Self {
        let home = std::env::var("HOME").ok().filter(|h| !h.is_empty());
        let base = home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
        FileTokenStore::at(base.join(format!(".{app}")).join("auth-token.json"))
    }

    /// The file path this store writes.
    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl TokenStore for FileTokenStore {
    fn load(&self) -> anyhow::Result<Option<TokenBundle>> {
        match std::fs::read(&self.path) {
            Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    fn save(&self, bundle: &TokenBundle) -> anyhow::Result<()> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_vec_pretty(bundle)?;
        std::fs::write(&self.path, &json)?;
        set_owner_only(&self.path)?;
        Ok(())
    }

    fn clear(&self) -> anyhow::Result<()> {
        match std::fs::remove_file(&self.path) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(e.into()),
        }
    }
}

/// An in-memory [`TokenStore`] — for tests and for a client that deliberately
/// keeps a token only for the process lifetime.
#[derive(Default)]
pub struct MemoryTokenStore {
    slot: std::sync::Mutex<Option<TokenBundle>>,
}

impl TokenStore for MemoryTokenStore {
    fn load(&self) -> anyhow::Result<Option<TokenBundle>> {
        Ok(self.slot.lock().unwrap().clone())
    }
    fn save(&self, bundle: &TokenBundle) -> anyhow::Result<()> {
        *self.slot.lock().unwrap() = Some(bundle.clone());
        Ok(())
    }
    fn clear(&self) -> anyhow::Result<()> {
        *self.slot.lock().unwrap() = None;
        Ok(())
    }
}

/// Set 0600 (owner read/write only) on unix; a no-op elsewhere.
fn set_owner_only(path: &Path) -> anyhow::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
    }
    #[cfg(not(unix))]
    let _ = path;
    Ok(())
}

/// Read a non-expired cached access token WITHOUT any network (the offline
/// surface). `Ok(None)` when nothing is stored or the cached token has expired —
/// the caller then runs [`login`] (online) or presents a re-auth prompt.
pub fn cached_token(store: &dyn TokenStore) -> anyhow::Result<Option<String>> {
    Ok(store
        .load()?
        .filter(|b| b.valid_at(now_unix(), DEFAULT_SKEW_SECS))
        .map(|b| b.access_token))
}

// ─── The networked login + refresh (online) ──────────────────────────────────

/// The IdP endpoints + client identity an [`login`] needs. Comes from an app's
/// OIDC config (the same issuer holger's server verifies against).
#[derive(Debug, Clone)]
pub struct LoginConfig {
    /// The device-authorization endpoint (RFC 8628 §3.1).
    pub device_auth_url: String,
    /// The token endpoint (device-code poll + refresh).
    pub token_url: String,
    /// The OAuth2 public client id registered for the CLI.
    pub client_id: String,
    /// Requested scope (e.g. `openid profile offline_access`). `offline_access`
    /// is what makes the IdP return a refresh token.
    pub scope: String,
}

/// Run the OIDC **device-authorization grant** (RFC 8628) to obtain a token:
/// request a device code, hand it to `on_prompt` (which shows the user the
/// verification URI + user code), then poll the token endpoint until the user
/// approves — persisting the resulting [`TokenBundle`] via `store`. This is what
/// `<app> login` calls. Reuses the device helper in [`nornir_auth_oidc::device`]
/// (no twin). `online`-only.
#[cfg(feature = "online")]
pub async fn login(
    cfg: &LoginConfig,
    store: &dyn TokenStore,
    on_prompt: impl Fn(&DeviceCode),
) -> anyhow::Result<TokenBundle> {
    let dc = device::request_device_code(&cfg.device_auth_url, &cfg.client_id, &cfg.scope)
        .await
        .map_err(anyhow::Error::msg)?;
    on_prompt(&dc);

    let interval = dc.interval.max(1);
    let expires_in = if dc.expires_in > 0 { dc.expires_in as i64 } else { 600 };
    let deadline = now_unix() + expires_in;

    loop {
        tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
        if now_unix() > deadline {
            anyhow::bail!("device-code login timed out before approval");
        }
        match device::poll_token_full(&cfg.token_url, &cfg.client_id, &dc.device_code)
            .await
            .map_err(anyhow::Error::msg)?
        {
            Some(tr) => {
                let bundle = TokenBundle::from_response(tr, now_unix(), None);
                store.save(&bundle)?;
                return Ok(bundle);
            }
            None => continue, // authorization_pending / slow_down
        }
    }
}

/// Return a currently-valid access token, **silently refreshing** an expired one
/// when a refresh token is held. `Ok(None)` when nothing is stored, or the token
/// expired and no refresh token is available (the caller runs [`login`] again).
/// `online`-only (it may hit the token endpoint); offline callers use
/// [`cached_token`]. This is the `current_token()` a client calls before each
/// authenticated request.
#[cfg(feature = "online")]
pub async fn current_token(
    cfg: &LoginConfig,
    store: &dyn TokenStore,
) -> anyhow::Result<Option<String>> {
    let Some(bundle) = store.load()? else {
        return Ok(None);
    };
    if bundle.valid_at(now_unix(), DEFAULT_SKEW_SECS) {
        return Ok(Some(bundle.access_token));
    }
    // Expired — refresh if we can, else signal "log in again".
    let Some(refresh_token) = bundle.refresh_token.clone() else {
        return Ok(None);
    };
    let tr = device::refresh(&cfg.token_url, &cfg.client_id, &refresh_token)
        .await
        .map_err(anyhow::Error::msg)?;
    let refreshed = TokenBundle::from_response(tr, now_unix(), Some(refresh_token));
    store.save(&refreshed)?;
    Ok(Some(refreshed.access_token))
}

// ─── Test support (the stub RFC 8628 IdP) ────────────────────────────────────

/// A minimal localhost **stub of the RFC 8628 IdP endpoints** — the test double
/// for the thing [`login`] talks to.
///
/// ## Why this lives in the leaf
///
/// This crate is the ONE client-side login the constellation consumes, so every
/// consumer that wires it up needs to *test* that wiring, and to do that needs
/// an IdP answering `/device` and `/token`. The crate shipped the client but no
/// server double, so korp, holger and dwarves (twice) each hand-rolled the SAME
/// ~40-line raw-tokio HTTP stub — four copies of a fixture, drifting apart, with
/// holger's copy even commenting that it "mirrors korp's". That is the missing
/// knob, and per LAW 5 the knob is ADDED to the leaf rather than forked. Any
/// consumer now writes:
///
/// ```ignore
/// let addr = nornir_auth_client::stub_idp::spawn("tok-abc").await;
/// let cfg = LoginConfig {
///     device_auth_url: format!("http://{addr}/device"),
///     token_url: format!("http://{addr}/token"),
///     ..
/// };
/// ```
///
/// No real network and no fixed port: it binds `127.0.0.1:0` and reports the
/// bound address. The `tokio::spawn`s here run **IO** (a mock HTTP server), NOT
/// CPU work — the gatling law targets compute parallelism, not IO servers.
///
/// Gated behind `test-support` (or this crate's own tests), so no stub server
/// rides in a release build unless a consumer explicitly turns it on for its
/// dev-dependencies.
#[cfg(any(test, feature = "test-support"))]
pub mod stub_idp {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    /// The knobs the four hand-rolled copies had baked in as literals. `Default`
    /// reproduces those literals exactly, so a consumer replacing its copy with
    /// [`spawn`] sees byte-identical responses.
    #[derive(Debug, Clone)]
    pub struct StubIdp {
        /// The access token `/token` hands back on approval.
        pub access_token: String,
        /// The refresh token `/token` hands back. `None` ⇒ omit the field, which
        /// is how you exercise the "expired, no refresh token" branch of
        /// [`super::current_token`].
        pub refresh_token: Option<String>,
        /// Access-token lifetime in seconds (drives `expires_at_unix`). Set this
        /// to `0` or a negative-equivalent short value to test staleness.
        pub expires_in: i64,
        /// The user code the pane displays.
        pub user_code: String,
        /// Seconds the client is told to wait between polls. Keep at 1 so tests
        /// are quick — [`super::login`] sleeps this long before its FIRST poll.
        pub interval: u64,
        /// Seconds until the device code expires.
        pub device_expires_in: i64,
    }

    impl Default for StubIdp {
        fn default() -> Self {
            Self {
                access_token: "stub-access-token".into(),
                refresh_token: Some("rt-456".into()),
                expires_in: 3600,
                user_code: "WDJB-MJHT".into(),
                interval: 1,
                device_expires_in: 600,
            }
        }
    }

    impl StubIdp {
        /// Bind on an ephemeral loopback port, serve `/device` + `/token` forever
        /// on a background task, and return the bound `addr`.
        pub async fn spawn(self) -> std::net::SocketAddr {
            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
            let addr = listener.local_addr().unwrap();
            let device_body = format!(
                r#"{{"device_code":"dc-123","user_code":"{user_code}","verification_uri":"http://{addr}/device","interval":{interval},"expires_in":{device_expires_in}}}"#,
                user_code = self.user_code,
                interval = self.interval,
                device_expires_in = self.device_expires_in,
            );
            let refresh = match &self.refresh_token {
                Some(rt) => format!(r#","refresh_token":"{rt}""#),
                None => String::new(),
            };
            let token_body = format!(
                r#"{{"access_token":"{access}"{refresh},"expires_in":{expires_in},"token_type":"Bearer"}}"#,
                access = self.access_token,
                expires_in = self.expires_in,
            );
            // A32-14, stays a task: the stub IdP's accept loop, a test double (feature test-support), never in a box.
            tokio::spawn(async move {
                loop {
                    let Ok((mut sock, _)) = listener.accept().await else { break };
                    let device_body = device_body.clone();
                    let token_body = token_body.clone();
                    // A32-14, stays a task: one stub IdP connection (a test double).
                    tokio::spawn(async move {
                        let mut buf = vec![0u8; 4096];
                        let n = sock.read(&mut buf).await.unwrap_or(0);
                        let req = String::from_utf8_lossy(&buf[..n]);
                        let path = req.split_whitespace().nth(1).unwrap_or("/");
                        let body =
                            if path.starts_with("/token") { &token_body } else { &device_body };
                        let resp = format!(
                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                            body.len(),
                            body
                        );
                        let _ = sock.write_all(resp.as_bytes()).await;
                        let _ = sock.shutdown().await;
                    });
                }
            });
            addr
        }
    }

    /// Bind on an ephemeral loopback port, serve the two endpoints forever on a
    /// background task, and return the bound `addr`. The default-approval stub:
    /// `/token` approves immediately with `access_token`.
    ///
    /// Takes `impl Into<String>` so the `&str` and `String` call sites of the
    /// copies this replaces both compile unchanged.
    pub async fn spawn(access_token: impl Into<String>) -> std::net::SocketAddr {
        // A32-14, stays a task: the stub IdP (a test double, feature test-support).
        StubIdp { access_token: access_token.into(), ..Default::default() }.spawn().await
    }

    /// An **unsigned, display-only** JWT `header.payload.sig` — the shape an IdP
    /// mints, for asserting a pane renders the `sub`/`email` it was handed. NOT a
    /// credential and NOT verifiable: anything that checks a signature must use
    /// `nornir_auth_oidc::test_support::sign` instead.
    pub fn display_jwt(payload: serde_json::Value) -> String {
        let b64 = |b: &[u8]| {
            use base64::Engine;
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b)
        };
        format!("{}.{}.sig", b64(br#"{"alg":"none"}"#), b64(payload.to_string().as_bytes()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn bundle(access: &str, expires_at: Option<i64>, refresh: Option<&str>) -> TokenBundle {
        TokenBundle {
            access_token: access.into(),
            refresh_token: refresh.map(String::from),
            expires_at_unix: expires_at,
            subject: None,
        }
    }

    /// Expiry logic (RED-when-broken): fresh before expiry, stale after (with
    /// skew), and an unknown expiry counts as valid.
    #[test]
    fn bundle_validity_honours_expiry_and_skew() {
        let now = 1_000_000;
        // Expires well in the future ⇒ valid.
        assert!(bundle("a", Some(now + 3600), None).valid_at(now, DEFAULT_SKEW_SECS));
        // Already past ⇒ stale.
        assert!(!bundle("a", Some(now - 1), None).valid_at(now, DEFAULT_SKEW_SECS));
        // Inside the skew window ⇒ treated as stale (refresh early).
        assert!(!bundle("a", Some(now + 5), None).valid_at(now, DEFAULT_SKEW_SECS));
        // Unknown lifetime ⇒ valid (caller re-auths on rejection).
        assert!(bundle("a", None, None).valid_at(now, DEFAULT_SKEW_SECS));
    }

    /// `from_response` stamps expiry from `expires_in` and PRESERVES a prior
    /// refresh token when the response omits one (RED-when-broken: a refresh grant
    /// that returns no rotated token must not wipe the refresh token we hold).
    #[test]
    fn from_response_stamps_expiry_and_preserves_refresh() {
        let tr = TokenResponse {
            access_token: "new-at".into(),
            refresh_token: None,
            expires_in: Some(3600),
            token_type: Some("Bearer".into()),
        };
        let b = TokenBundle::from_response(tr, 1_000, Some("kept-rt".into()));
        assert_eq!(b.access_token, "new-at");
        assert_eq!(b.expires_at_unix, Some(4_600));
        assert_eq!(b.refresh_token.as_deref(), Some("kept-rt"), "prior refresh preserved");

        // A rotated refresh token in the response WINS over the prior one.
        let tr2 = TokenResponse {
            access_token: "at2".into(),
            refresh_token: Some("rotated-rt".into()),
            expires_in: None,
            token_type: None,
        };
        let b2 = TokenBundle::from_response(tr2, 1_000, Some("old-rt".into()));
        assert_eq!(b2.refresh_token.as_deref(), Some("rotated-rt"));
        assert_eq!(b2.expires_at_unix, None);
    }

    /// `cached_token` returns a fresh token and hides an expired one (RED-when-broken).
    #[test]
    fn cached_token_returns_fresh_hides_expired() {
        let store = MemoryTokenStore::default();
        assert!(cached_token(&store).unwrap().is_none(), "empty store ⇒ None");

        store.save(&bundle("fresh-at", Some(now_unix() + 3600), None)).unwrap();
        assert_eq!(cached_token(&store).unwrap().as_deref(), Some("fresh-at"));

        store.save(&bundle("stale-at", Some(now_unix() - 10), None)).unwrap();
        assert!(cached_token(&store).unwrap().is_none(), "expired cached token is not returned");
    }

    /// File store round-trip (RED-when-broken): save → load equal, clear removes,
    /// and the file is 0600 (it holds a secret).
    #[test]
    fn file_store_round_trips_and_is_owner_only() {
        let dir = tempfile::tempdir().unwrap();
        let store = FileTokenStore::at(dir.path().join("nested").join("auth-token.json"));
        assert!(store.load().unwrap().is_none(), "absent ⇒ None");

        let b = bundle("acc-123", Some(1_700_000_000), Some("ref-456"));
        store.save(&b).unwrap();
        assert_eq!(store.load().unwrap().as_ref(), Some(&b), "round-trips");

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(store.path()).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600, "token file must be 0600");
        }

        store.clear().unwrap();
        assert!(store.load().unwrap().is_none(), "clear removes the stored bundle");
        store.clear().unwrap(); // idempotent
    }

    /// The `for_app` convention derives `$HOME/.<app>/auth-token.json`.
    #[test]
    fn for_app_path_convention() {
        let prev = std::env::var("HOME").ok();
        std::env::set_var("HOME", "/home/tester");
        let s = FileTokenStore::for_app("korp");
        assert!(s.path().ends_with(".korp/auth-token.json"), "path was {:?}", s.path());
        match prev {
            Some(h) => std::env::set_var("HOME", h),
            None => std::env::remove_var("HOME"),
        }
    }
}

/// **The stub IdP drives the REAL login end-to-end.** While this fixture was
/// copied into four downstreams it was never itself tested — each copy was only
/// ever exercised through its own consumer's pane. Now that it lives here, it
/// gets the assertions that keep it honest, so a change to `stub_idp` cannot
/// silently break korp/holger/dwarves at once.
#[cfg(all(test, feature = "online"))]
mod stub_idp_tests {
    use super::stub_idp::{self, StubIdp};
    use super::*;

    fn cfg_for(addr: std::net::SocketAddr) -> LoginConfig {
        LoginConfig {
            device_auth_url: format!("http://{addr}/device"),
            token_url: format!("http://{addr}/token"),
            client_id: "test-client".into(),
            scope: "openid offline_access".into(),
        }
    }

    /// RED WHEN BROKEN: the stub must speak well-formed RFC 8628 on BOTH
    /// endpoints. Break either JSON body (or route `/token` to the device body)
    /// and `login` fails to produce a bundle. Asserts the prompt the user would
    /// see AND the token that gets persisted.
    #[tokio::test]
    async fn login_against_the_stub_yields_and_persists_a_token() {
        let addr = stub_idp::spawn("tok-abc").await;
        let store = MemoryTokenStore::default();

        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
        let sink = seen.clone();
        let bundle = login(&cfg_for(addr), &store, |dc| {
            *sink.lock().unwrap() = Some((dc.user_code.clone(), dc.verification_uri.clone()));
        })
        .await
        .expect("the stub IdP must carry a real device-code login to completion");

        // The prompt the pane shows the operator came from /device.
        let (user_code, uri) = seen.lock().unwrap().clone().expect("on_prompt ran");
        assert_eq!(user_code, "WDJB-MJHT");
        assert!(uri.contains(&addr.to_string()), "verification uri points at the stub: {uri}");

        // The token came from /token and was persisted through the store seam.
        assert_eq!(bundle.access_token, "tok-abc");
        assert_eq!(bundle.refresh_token.as_deref(), Some("rt-456"));
        assert!(bundle.expires_at_unix.is_some(), "expires_in must become an absolute expiry");
        assert_eq!(store.load().unwrap().unwrap().access_token, "tok-abc");

        // And it reads back as currently valid — the whole point of logging in.
        assert_eq!(
            current_token(&cfg_for(addr), &store).await.unwrap().as_deref(),
            Some("tok-abc"),
        );
    }

    /// The knobs are real knobs, not decoration: no refresh token + an already
    /// stale access token is the "must log in again" branch, and `current_token`
    /// must report `None` rather than handing back a dead credential.
    #[tokio::test]
    async fn no_refresh_token_and_a_stale_access_token_means_log_in_again() {
        let addr = StubIdp {
            access_token: "short-lived".into(),
            refresh_token: None,
            expires_in: 0,
            ..Default::default()
        }
        .spawn()
        .await;
        let store = MemoryTokenStore::default();
        let bundle = login(&cfg_for(addr), &store, |_| {}).await.unwrap();
        assert_eq!(bundle.access_token, "short-lived");
        assert!(bundle.refresh_token.is_none(), "the stub omitted refresh_token as asked");

        // Expired with nothing to refresh with → None (NOT the stale token).
        assert_eq!(current_token(&cfg_for(addr), &store).await.unwrap(), None);
    }

    /// `display_jwt` is display-only but must still be a decodable three-part
    /// JWT carrying the claims handed in — that is all a pane reads off it.
    #[test]
    fn display_jwt_carries_its_claims_in_a_decodable_payload() {
        use base64::Engine;
        let t = stub_idp::display_jwt(serde_json::json!({"sub": "robot@vetra.se"}));
        let parts: Vec<&str> = t.split('.').collect();
        assert_eq!(parts.len(), 3, "header.payload.sig");
        let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
        let claims: serde_json::Value = serde_json::from_slice(&raw).unwrap();
        assert_eq!(claims["sub"], "robot@vetra.se");
    }
}