Menu
Public repository / 338143b197dc5a0a325ac6e7683123936032e6e3
AkurAI-VPN / crates/akurai-control/src/auth.rs
Latest change e248609fc3912bf5c10b57b3ab1dc2e9f2a36657 - feat(control): persistent + expiring session store (logins survive restarts) by Claude Code
//! OIDC Authorization Code Flow helpers — std-only, zero external crates.
//!
//! JWT verification is delegated to the IDP's `/introspect` endpoint (called
//! via `curl`). The IDP verifies the EdDSA signature, expiry, and returns
//! claim data. We additionally validate issuer and audience client-side.
//!
//! **Required env vars** (server logs a warning and disables login if any
//! are absent):
//! `OIDC_CLIENT_ID` — client ID registered in the AkurAI IDP
//! `OIDC_CLIENT_SECRET` — client secret for the token endpoint
//! `OIDC_REDIRECT_URI` — callback URL, e.g. `https://vpn.olibuijr.com/auth/callback`
//! `OIDC_ISSUER_URL` — IDP base URL (default: `https://auth.olibuijr.com`)
use std::collections::HashMap;
use std::io::{Read, Write};
// ---------------------------------------------------------------------------
// AuthUser & session store
// ---------------------------------------------------------------------------
/// Authenticated principal decoded from the OIDC id_token payload.
#[derive(Debug, Clone)]
pub struct AuthUser {
/// OIDC subject identifier (opaque, stable per user).
pub sub: String,
pub email: String,
/// Display name from the `name` or `preferred_username` claim.
pub name: String,
}
/// A single persisted session record.
#[derive(Debug)]
struct SessionRecord {
user: AuthUser,
csrf: String,
expires_at: u64,
}
/// Persistent session store backed by `$AKURAI_DATA_DIR/sessions.json`.
///
/// Records are written atomically on every insert/remove and loaded at startup.
/// Expired records (where `expires_at <= now`) are dropped on load and excluded
/// from every `get`/`csrf` lookup and from every `persist()` write.
#[derive(Debug, Default)]
pub struct SessionStore {
sessions: HashMap<String, SessionRecord>,
}
impl SessionStore {
/// Create an empty in-memory store (used by tests and as an explicit blank slate).
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
/// Load sessions from disk, silently dropping expired records.
///
/// Missing file, empty file, or parse errors all produce an empty store.
pub fn load() -> Self {
let path = sessions_path();
let mut content = String::new();
if let Ok(mut f) = std::fs::File::open(&path) {
let _ = f.read_to_string(&mut content);
}
let now = crate::vpn_endpoint::now_secs();
let sessions = parse_sessions(&content)
.into_iter()
.filter(|(_, rec)| rec.expires_at > now)
.collect();
Self { sessions }
}
/// Insert a new session with a CSRF token and a TTL, then persist to disk.
pub fn insert(&mut self, token: String, user: AuthUser, csrf: String, ttl_secs: u64) {
let expires_at = crate::vpn_endpoint::now_secs() + ttl_secs;
self.sessions.insert(
token,
SessionRecord {
user,
csrf,
expires_at,
},
);
self.persist();
}
/// Return the authenticated user for a valid, non-expired session token.
pub fn get(&self, token: &str) -> Option<&AuthUser> {
let rec = self.sessions.get(token)?;
if rec.expires_at <= crate::vpn_endpoint::now_secs() {
return None;
}
Some(&rec.user)
}
/// Return the CSRF token for a valid, non-expired session token.
pub fn csrf(&self, token: &str) -> Option<&str> {
let rec = self.sessions.get(token)?;
if rec.expires_at <= crate::vpn_endpoint::now_secs() {
return None;
}
Some(&rec.csrf)
}
/// Remove a session and immediately persist the updated store.
pub fn remove(&mut self, token: &str) {
self.sessions.remove(token);
self.persist();
}
/// Atomically write all non-expired records to `sessions.json` via a
/// temp-file rename, mirroring the pattern in `vpn_endpoint::save`.
fn persist(&self) {
let path = sessions_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let now = crate::vpn_endpoint::now_secs();
let mut lines: Vec<String> = Vec::new();
for (token, rec) in &self.sessions {
if rec.expires_at <= now {
continue;
}
lines.push(format!(
"{{\"token\":\"{}\",\"sub\":\"{}\",\"email\":\"{}\",\
\"name\":\"{}\",\"csrf\":\"{}\",\"expires_at\":{}}}",
session_json_esc(token),
session_json_esc(&rec.user.sub),
session_json_esc(&rec.user.email),
session_json_esc(&rec.user.name),
session_json_esc(&rec.csrf),
rec.expires_at,
));
}
let json = format!("[\n{}\n]\n", lines.join(",\n"));
let tmp = path.with_extension("json.tmp");
if let Ok(mut f) = std::fs::File::create(&tmp) {
let _ = f.write_all(json.as_bytes());
let _ = f.flush();
let _ = std::fs::rename(&tmp, &path);
}
}
}
// ---------------------------------------------------------------------------
// Session file helpers
// ---------------------------------------------------------------------------
/// Resolved path to the sessions data file.
fn sessions_path() -> std::path::PathBuf {
let dir = std::env::var("AKURAI_DATA_DIR").unwrap_or_else(|_| "./data".to_string());
std::path::Path::new(&dir).join("sessions.json")
}
/// JSON-escape a string value (backslash, quote, newlines).
fn session_json_esc(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// Extract a JSON u64 value by key from a flat JSON object.
fn extract_json_u64(json: &str, key: &str) -> Option<u64> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)?;
let rest = json[start + needle.len()..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
}
/// Split a JSON array body into individual object strings by brace depth.
fn split_session_objects(s: &str) -> Vec<String> {
let mut objects = Vec::new();
let mut depth: i32 = 0;
let mut start: Option<usize> = None;
for (i, ch) in s.char_indices() {
match ch {
'{' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
if let Some(s_pos) = start.take() {
objects.push(s[s_pos..=i].to_string());
}
}
}
_ => {}
}
}
objects
}
/// Parse a sessions JSON file body into `(token, SessionRecord)` pairs.
fn parse_sessions(json: &str) -> Vec<(String, SessionRecord)> {
split_session_objects(json.trim())
.into_iter()
.filter_map(|obj| {
let token = extract_json_str(&obj, "token")?;
let sub = extract_json_str(&obj, "sub").unwrap_or_default();
let email = extract_json_str(&obj, "email").unwrap_or_default();
let name = extract_json_str(&obj, "name").unwrap_or_default();
let csrf = extract_json_str(&obj, "csrf").unwrap_or_default();
let expires_at = extract_json_u64(&obj, "expires_at").unwrap_or(0);
Some((
token,
SessionRecord {
user: AuthUser { sub, email, name },
csrf,
expires_at,
},
))
})
.collect()
}
// ---------------------------------------------------------------------------
// OIDC config
// ---------------------------------------------------------------------------
/// Configuration loaded from environment variables.
pub struct OidcConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub issuer_url: String,
}
impl OidcConfig {
/// Returns `None` if any required env var is missing.
pub fn from_env() -> Option<Self> {
Some(Self {
client_id: std::env::var("OIDC_CLIENT_ID").ok()?,
client_secret: std::env::var("OIDC_CLIENT_SECRET").ok()?,
redirect_uri: std::env::var("OIDC_REDIRECT_URI").ok()?,
issuer_url: std::env::var("OIDC_ISSUER_URL")
.unwrap_or_else(|_| "https://auth.olibuijr.com".to_string()),
})
}
}
// ---------------------------------------------------------------------------
// OIDC flow helpers
// ---------------------------------------------------------------------------
/// Build the redirect URL for the OIDC authorization endpoint.
pub fn build_authorize_url(config: &OidcConfig, state: &str) -> String {
format!(
"{}/authorize?response_type=code&client_id={}&redirect_uri={}&scope=openid+email+profile&state={}",
config.issuer_url,
url_encode(&config.client_id),
url_encode(&config.redirect_uri),
url_encode(state),
)
}
/// Exchange an authorization code for tokens by spawning `curl`.
///
/// **Runtime requirement**: `curl` must be on PATH. The IDP token endpoint is
/// HTTPS; a std-only TLS client is not possible without a crypto crate (see
/// the UNRESOLVED DECISION in `Cargo.toml`). `curl` on the server host is the
/// pragmatic workaround for the MVP.
pub fn exchange_code(config: &OidcConfig, code: &str) -> Result<String, String> {
let token_url = format!("{}/token", config.issuer_url);
let body = format!(
"grant_type=authorization_code&code={code}&redirect_uri={redirect}&client_id={id}&client_secret={secret}",
code = url_encode(code),
redirect = url_encode(&config.redirect_uri),
id = url_encode(&config.client_id),
secret = url_encode(&config.client_secret),
);
let output = std::process::Command::new("curl")
.args([
"-s",
"--max-time",
"10",
"-X",
"POST",
"-H",
"Content-Type: application/x-www-form-urlencoded",
"-d",
&body,
&token_url,
])
.output()
.map_err(|e| format!("curl exec failed: {e}"))?;
if !output.status.success() {
return Err(format!(
"curl exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
));
}
String::from_utf8(output.stdout).map_err(|e| format!("curl output not UTF-8: {e}"))
}
/// Extract the `id_token` string from a token endpoint JSON response body.
pub fn extract_id_token(json: &str) -> Option<String> {
extract_json_str(json, "id_token")
}
/// Verify an id_token by calling the IDP's `/introspect` endpoint.
///
/// The IDP performs full EdDSA signature verification and expiry checking.
/// This function additionally validates issuer and audience locally.
///
/// Returns `Ok(AuthUser)` on success or `Err(reason)` if the token is invalid,
/// expired, has the wrong audience, or cannot be reached.
pub fn verify_id_token(config: &OidcConfig, id_token: &str) -> Result<AuthUser, String> {
let introspect_url = format!("{}/introspect", config.issuer_url);
let body = format!(
"token={token}&token_type_hint=access_token&client_id={cid}",
token = url_encode(id_token),
cid = url_encode(&config.client_id),
);
let output = std::process::Command::new("curl")
.args([
"-s",
"--max-time",
"10",
"-X",
"POST",
"-H",
"Content-Type: application/x-www-form-urlencoded",
"-d",
&body,
&introspect_url,
])
.output()
.map_err(|e| format!("curl exec failed: {e}"))?;
if !output.status.success() {
return Err(format!(
"curl exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
));
}
let json =
String::from_utf8(output.stdout).map_err(|e| format!("curl output not UTF-8: {e}"))?;
verify_introspect_response(&json, &config.client_id, &config.issuer_url)
}
/// Validate an introspect JSON response and extract the authenticated user.
/// Separated from the curl call so it can be unit-tested.
pub fn verify_introspect_response(
json: &str,
expected_client_id: &str,
expected_issuer: &str,
) -> Result<AuthUser, String> {
let active = extract_json_bool(json, "active").unwrap_or(false);
if !active {
return Err("token rejected by IDP (expired, invalid signature, or revoked)".to_string());
}
let iss = extract_json_str(json, "iss").unwrap_or_default();
if !iss.is_empty() && iss != expected_issuer {
return Err(format!(
"issuer mismatch: got '{iss}', expected '{expected_issuer}'"
));
}
let aud = extract_json_str(json, "aud").unwrap_or_default();
if !aud.is_empty() && aud != expected_client_id {
return Err(format!(
"audience mismatch: got '{aud}', expected '{expected_client_id}'"
));
}
Ok(AuthUser {
sub: extract_json_str(json, "sub").unwrap_or_default(),
email: extract_json_str(json, "email").unwrap_or_default(),
name: extract_json_str(json, "name")
.or_else(|| extract_json_str(json, "preferred_username"))
.unwrap_or_default(),
})
}
/// Decode the JWT payload and return user identity fields (no signature check).
///
/// Used as a fallback when the introspect endpoint is unreachable. Prefer
/// `verify_id_token` for production code paths.
#[allow(dead_code)]
pub fn decode_jwt_claims(id_token: &str) -> Option<AuthUser> {
let payload_b64 = id_token.split('.').nth(1)?;
let bytes = base64url_decode(payload_b64)?;
let payload = String::from_utf8(bytes).ok()?;
Some(AuthUser {
sub: extract_json_str(&payload, "sub").unwrap_or_default(),
email: extract_json_str(&payload, "email").unwrap_or_default(),
name: extract_json_str(&payload, "name")
.or_else(|| extract_json_str(&payload, "preferred_username"))
.unwrap_or_default(),
})
}
// ---------------------------------------------------------------------------
// Session / cookie helpers
// ---------------------------------------------------------------------------
/// Generate a random 64-hex-char session token from `/dev/urandom`.
pub fn random_token() -> String {
let mut buf = [0u8; 32];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
let _ = f.read_exact(&mut buf);
}
buf.iter().map(|b| format!("{b:02x}")).collect()
}
/// Extract the `akurai_session` value from a raw Cookie header string.
pub fn parse_session_cookie(cookie_header: &str) -> Option<String> {
for part in cookie_header.split(';') {
if let Some(val) = part.trim().strip_prefix("akurai_session=") {
return Some(val.trim().to_string());
}
}
None
}
// ---------------------------------------------------------------------------
// URL encode / decode
// ---------------------------------------------------------------------------
/// Percent-encode a string for inclusion in a URL query parameter value.
pub fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Percent-decode a query value or form field.
pub fn url_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
out.push(char::from(
from_hex(bytes[i + 1]) << 4 | from_hex(bytes[i + 2]),
));
i += 3;
} else if bytes[i] == b'+' {
out.push(' ');
i += 1;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn from_hex(b: u8) -> u8 {
match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => 0,
}
}
/// Parse an `application/x-www-form-urlencoded` body.
pub fn parse_form(body: &str) -> HashMap<String, String> {
body.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(k, v)| (url_decode(k), url_decode(v)))
.collect()
}
// ---------------------------------------------------------------------------
// Minimal JSON extraction (no serde — zero crate dep)
// ---------------------------------------------------------------------------
/// Extract a JSON boolean value by key from a flat JSON object.
pub fn extract_json_bool(json: &str, key: &str) -> Option<bool> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)?;
let rest = json[start + needle.len()..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
if rest.starts_with("true") {
Some(true)
} else if rest.starts_with("false") {
Some(false)
} else {
None
}
}
/// Extract a JSON string value by key from a flat JSON object.
///
/// Handles simple cases only: does not parse deep nesting, but correctly
/// handles `\"` escapes inside values.
pub fn extract_json_str(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)?;
let rest = json[start + needle.len()..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let inner = rest.strip_prefix('"')?;
let end = json_str_end(inner)?;
Some(inner[..end].replace("\\\"", "\"").replace("\\\\", "\\"))
}
fn json_str_end(s: &str) -> Option<usize> {
let mut chars = s.char_indices();
while let Some((i, c)) = chars.next() {
if c == '\\' {
chars.next();
} else if c == '"' {
return Some(i);
}
}
None
}
/// Extract a JSON string array by key from a flat JSON object.
///
/// Items must not contain commas (fine for IP prefixes, keys, names).
pub fn extract_json_str_array(json: &str, key: &str) -> Vec<String> {
let needle = format!("\"{key}\"");
let Some(start) = json.find(&needle) else {
return Vec::new();
};
let rest = json[start + needle.len()..].trim_start();
let Some(rest) = rest.strip_prefix(':') else {
return Vec::new();
};
let rest = rest.trim_start();
if !rest.starts_with('[') {
return Vec::new();
}
let end = rest.find(']').unwrap_or(rest.len());
rest[1..end]
.split(',')
.map(str::trim)
.filter_map(|s| s.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.map(str::to_string)
.collect()
}
// ---------------------------------------------------------------------------
// Base64url decode (for JWT payload — no external crate)
// ---------------------------------------------------------------------------
#[allow(dead_code)]
fn base64url_decode(s: &str) -> Option<Vec<u8>> {
let standard = s.replace('-', "+").replace('_', "/");
let rem = standard.len() % 4;
let padded = if rem == 0 {
standard
} else {
format!("{standard}{}", "=".repeat(4 - rem))
};
base64_decode(&padded)
}
#[allow(dead_code)]
const B64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
#[allow(dead_code)]
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let chars: Vec<u8> = s
.bytes()
.filter(|&b| b != b'=')
.map(|b| B64_TABLE.iter().position(|&c| c == b).map(|p| p as u8))
.collect::<Option<Vec<_>>>()?;
let mut out = Vec::with_capacity(chars.len() * 3 / 4);
for chunk in chars.chunks(4) {
match *chunk {
[a, b, c, d] => {
out.push((a << 2) | (b >> 4));
out.push((b << 4) | (c >> 2));
out.push((c << 6) | d);
}
[a, b, c] => {
out.push((a << 2) | (b >> 4));
out.push((b << 4) | (c >> 2));
}
[a, b] => {
out.push((a << 2) | (b >> 4));
}
_ => {}
}
}
Some(out)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- verify_introspect_response --
#[test]
fn introspect_active_good_claims() {
let json = r#"{"active":true,"iss":"https://auth.olibuijr.com","aud":"client-abc","sub":"user-1","email":"user@example.com","name":"Test User"}"#;
let user =
verify_introspect_response(json, "client-abc", "https://auth.olibuijr.com").unwrap();
assert_eq!(user.sub, "user-1");
assert_eq!(user.email, "user@example.com");
assert_eq!(user.name, "Test User");
}
#[test]
fn introspect_inactive_rejected() {
let json = r#"{"active":false}"#;
let err = verify_introspect_response(json, "client-abc", "https://auth.olibuijr.com")
.unwrap_err();
assert!(err.contains("rejected by IDP"), "got: {err}");
}
#[test]
fn introspect_wrong_audience() {
let json = r#"{"active":true,"iss":"https://auth.olibuijr.com","aud":"other-client","sub":"u","email":"u@x.com"}"#;
let err = verify_introspect_response(json, "client-abc", "https://auth.olibuijr.com")
.unwrap_err();
assert!(err.contains("audience mismatch"), "got: {err}");
}
#[test]
fn introspect_wrong_issuer() {
let json = r#"{"active":true,"iss":"https://evil.example.com","aud":"client-abc","sub":"u","email":"u@x.com"}"#;
let err = verify_introspect_response(json, "client-abc", "https://auth.olibuijr.com")
.unwrap_err();
assert!(err.contains("issuer mismatch"), "got: {err}");
}
#[test]
fn introspect_missing_iss_aud_still_accepted() {
// IDP may omit iss/aud if token is valid; we accept it (defensive)
let json = r#"{"active":true,"sub":"u","email":"u@x.com"}"#;
let user =
verify_introspect_response(json, "client-abc", "https://auth.olibuijr.com").unwrap();
assert_eq!(user.sub, "u");
}
// -- extract_json_bool --
#[test]
fn json_bool_true() {
assert_eq!(
extract_json_bool(r#"{"active":true}"#, "active"),
Some(true)
);
}
#[test]
fn json_bool_false() {
assert_eq!(
extract_json_bool(r#"{"active":false}"#, "active"),
Some(false)
);
}
#[test]
fn json_bool_missing() {
assert_eq!(extract_json_bool(r#"{"foo":"bar"}"#, "active"), None);
}
// -- extract_json_str (regression) --
#[test]
fn json_str_basic() {
assert_eq!(
extract_json_str(r#"{"email":"user@example.com"}"#, "email"),
Some("user@example.com".to_string())
);
}
// -- url_encode / url_decode roundtrip --
#[test]
fn url_encode_decode_roundtrip() {
let original = "https://vpn.olibuijr.com/auth/callback?foo=bar&baz=qux";
assert_eq!(url_decode(&url_encode(original)), original);
}
// -- parse_session_cookie --
#[test]
fn session_cookie_found() {
let cookie = "other=val; akurai_session=abc123; another=x";
assert_eq!(parse_session_cookie(cookie), Some("abc123".to_string()));
}
#[test]
fn session_cookie_missing() {
assert_eq!(parse_session_cookie("foo=bar"), None);
}
// -- SessionStore --
#[test]
fn session_insert_get_roundtrip() {
let mut store = SessionStore::new();
let user = AuthUser {
sub: "sub-1".to_string(),
email: "a@example.com".to_string(),
name: "Alice".to_string(),
};
// Use a large TTL so the record does not expire during the test.
store.sessions.insert(
"tok-a".to_string(),
SessionRecord {
user: user.clone(),
csrf: "csrf-xyz".to_string(),
expires_at: crate::vpn_endpoint::now_secs() + 3600,
},
);
let got = store.get("tok-a").expect("should find user");
assert_eq!(got.sub, "sub-1");
assert_eq!(got.email, "a@example.com");
assert_eq!(got.name, "Alice");
assert_eq!(store.csrf("tok-a"), Some("csrf-xyz"));
}
#[test]
fn session_expired_returns_none() {
let mut store = SessionStore::new();
let user = AuthUser {
sub: "sub-2".to_string(),
email: "b@example.com".to_string(),
name: "Bob".to_string(),
};
// expires_at = 1 is safely in the past.
store.sessions.insert(
"tok-expired".to_string(),
SessionRecord {
user,
csrf: "csrf-old".to_string(),
expires_at: 1,
},
);
assert!(
store.get("tok-expired").is_none(),
"expired token must return None"
);
assert!(
store.csrf("tok-expired").is_none(),
"expired token csrf must return None"
);
}
#[test]
fn session_persist_load_roundtrip() {
// Unique directory derived from the test name — avoids parallel-test collisions.
let dir = "/tmp/akurai-ctl-test-sess-plrt";
let _ = std::fs::remove_dir_all(dir);
std::fs::create_dir_all(dir).unwrap();
std::env::set_var("AKURAI_DATA_DIR", dir);
// Build a store with a future-expiry record and persist it.
let mut store = SessionStore::new();
let user = AuthUser {
sub: "u-persist".to_string(),
email: "p@example.com".to_string(),
name: "Persist".to_string(),
};
store.insert("tok-persist".to_string(), user, "csrf-p".to_string(), 3600);
// Overwrite the sessions file to also include a past-expiry record,
// simulating a record that expired while the server was running.
let future_exp = crate::vpn_endpoint::now_secs() + 3600;
let json = format!(
"[\n\
{{\"token\":\"tok-persist\",\"sub\":\"u-persist\",\"email\":\"p@example.com\",\
\"name\":\"Persist\",\"csrf\":\"csrf-p\",\"expires_at\":{future_exp}}},\n\
{{\"token\":\"tok-dead\",\"sub\":\"u-dead\",\"email\":\"d@example.com\",\
\"name\":\"Dead\",\"csrf\":\"csrf-d\",\"expires_at\":1}}\n]\n"
);
std::fs::write(format!("{dir}/sessions.json"), json).unwrap();
// load() must retain the future record and drop the past one.
let loaded = SessionStore::load();
let got = loaded
.get("tok-persist")
.expect("future-expiry record must survive load");
assert_eq!(got.sub, "u-persist");
assert!(
loaded.get("tok-dead").is_none(),
"past-expiry record must be pruned on load"
);
}
}