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");
}
}