use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use tokio::sync::Mutex;
pub const CLIENT_ID: &str = "hotdata-rust-sdk";
pub const LEEWAY_SECS: u64 = 30;
pub const TIMEOUT_SECS: u64 = 30;
const DEFAULT_EXPIRES_IN: u64 = 300;
const DISABLE_ENV: &str = "HOTDATA_DISABLE_JWT_EXCHANGE";
const DISABLE_VALUES: [&str; 4] = ["1", "true", "yes", "on"];
#[derive(Debug)]
#[non_exhaustive]
pub enum TokenExchangeError {
Transport(reqwest::Error),
Status { status: u16, body: String },
Malformed(String),
}
impl std::fmt::Display for TokenExchangeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenExchangeError::Transport(e) => {
write!(f, "token exchange transport error: {e}")
}
TokenExchangeError::Status { status, body } => {
write!(f, "token exchange failed: HTTP {status}: {body}")
}
TokenExchangeError::Malformed(msg) => {
write!(f, "malformed token response: {msg}")
}
}
}
}
impl std::error::Error for TokenExchangeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
TokenExchangeError::Transport(e) => Some(e),
_ => None,
}
}
}
#[async_trait::async_trait]
pub trait BearerTokenProvider: Send + Sync + std::fmt::Debug {
async fn bearer_value(&self) -> Result<String, TokenExchangeError>;
}
pub type PersistCallback = Arc<dyn Fn(&str, Option<&str>, u64) + Send + Sync>;
#[non_exhaustive]
pub struct TokenManagerOptions {
pub client_id: String,
pub token_path: String,
pub base_path: String,
pub seed_refresh: Option<String>,
pub seed_jwt: Option<(String, u64)>,
pub on_persist: Option<PersistCallback>,
}
impl Default for TokenManagerOptions {
fn default() -> Self {
TokenManagerOptions {
client_id: CLIENT_ID.to_string(),
token_path: "/v1/auth/jwt".to_string(),
base_path: String::new(),
seed_refresh: None,
seed_jwt: None,
on_persist: None,
}
}
}
impl std::fmt::Debug for TokenManagerOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenManagerOptions")
.field("client_id", &self.client_id)
.field("token_path", &self.token_path)
.field("base_path", &self.base_path)
.field("seed_refresh", &self.seed_refresh.as_ref().map(|_| "<redacted>"))
.field("seed_jwt", &self.seed_jwt.as_ref().map(|(_, exp)| ("<redacted>", exp)))
.field("on_persist", &self.on_persist.as_ref().map(|_| "<fn>"))
.finish()
}
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: Option<u64>,
refresh_token: Option<String>,
}
#[derive(Debug, Default)]
struct TokenState {
jwt: Option<String>,
exp: u64,
refresh: Option<String>,
}
pub struct TokenManager {
credential: String,
client: reqwest::Client,
base_path: String,
token_path: String,
client_id: String,
on_persist: Option<PersistCallback>,
state: Mutex<TokenState>,
}
impl std::fmt::Debug for TokenManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenManager")
.field("credential", &"<redacted>")
.field("base_path", &self.base_path)
.field("token_path", &self.token_path)
.field("client_id", &self.client_id)
.field("on_persist", &self.on_persist.as_ref().map(|_| "<fn>"))
.finish()
}
}
impl TokenManager {
pub fn new(
credential: impl Into<String>,
client: reqwest::Client,
base_path: impl Into<String>,
) -> Self {
TokenManager::with_options(
credential,
client,
TokenManagerOptions {
base_path: base_path.into(),
..Default::default()
},
)
}
pub fn with_options(
credential: impl Into<String>,
client: reqwest::Client,
opts: TokenManagerOptions,
) -> Self {
let mut state = TokenState::default();
if let Some(refresh) = opts.seed_refresh {
state.refresh = Some(refresh);
}
if let Some((jwt, exp)) = opts.seed_jwt {
state.jwt = Some(jwt);
state.exp = exp;
}
TokenManager {
credential: credential.into(),
client,
base_path: opts.base_path,
token_path: opts.token_path,
client_id: opts.client_id,
on_persist: opts.on_persist,
state: Mutex::new(state),
}
}
fn needs_exchange(&self) -> bool {
if disable_exchange_env() {
return false;
}
!self.credential.starts_with("eyJ")
}
async fn mint(&self, grant: &[(&str, &str)]) -> Result<TokenResponse, TokenExchangeError> {
let url = format!(
"{}{}",
self.base_path.trim_end_matches('/'),
self.token_path
);
let mut params: Vec<(&str, &str)> = grant.to_vec();
params.push(("client_id", &self.client_id));
let req = self
.client
.post(&url)
.form(¶ms)
.timeout(Duration::from_secs(TIMEOUT_SECS))
.build()
.map_err(TokenExchangeError::Transport)?;
crate::http_log::log_request(&req);
let resp = self
.client
.execute(req)
.await
.map_err(TokenExchangeError::Transport)?;
let status = resp.status();
crate::http_log::log_response_status(status);
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
crate::http_log::log_response_body(&body);
let body: String = body.chars().take(200).collect();
return Err(TokenExchangeError::Status {
status: status.as_u16(),
body,
});
}
let text = resp.text().await.map_err(TokenExchangeError::Transport)?;
crate::http_log::log_response_body(&text);
serde_json::from_str::<TokenResponse>(&text)
.map_err(|e| TokenExchangeError::Malformed(e.to_string()))
}
fn apply(state: &mut TokenState, resp: TokenResponse) {
let expires_in = resp.expires_in.unwrap_or(DEFAULT_EXPIRES_IN);
state.jwt = Some(resp.access_token);
state.exp = now_unix().saturating_add(expires_in);
if let Some(refresh) = resp.refresh_token {
state.refresh = Some(refresh);
}
}
fn apply_and_persist(&self, state: &mut TokenState, resp: TokenResponse) {
Self::apply(state, resp);
if let Some(cb) = &self.on_persist {
if let Some(jwt) = &state.jwt {
cb(jwt, state.refresh.as_deref(), state.exp);
}
}
}
}
#[async_trait::async_trait]
impl BearerTokenProvider for TokenManager {
async fn bearer_value(&self) -> Result<String, TokenExchangeError> {
if !self.needs_exchange() {
return Ok(self.credential.clone());
}
let mut state = self.state.lock().await;
if let Some(ref jwt) = state.jwt {
if now_unix() + LEEWAY_SECS < state.exp {
return Ok(jwt.clone());
}
}
if let Some(refresh) = state.refresh.clone() {
match self
.mint(&[("grant_type", "refresh_token"), ("refresh_token", &refresh)])
.await
{
Ok(resp) => self.apply_and_persist(&mut state, resp),
Err(_) => state.refresh = None,
}
}
let needs_mint = match state.jwt {
Some(_) => now_unix() + LEEWAY_SECS >= state.exp,
None => true,
};
if needs_mint {
let resp = self
.mint(&[("grant_type", "api_token"), ("api_token", &self.credential)])
.await?;
self.apply_and_persist(&mut state, resp);
}
Ok(state.jwt.clone().expect("jwt set after successful mint"))
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn disable_exchange_env() -> bool {
match std::env::var(DISABLE_ENV) {
Ok(v) => DISABLE_VALUES.contains(&v.trim().to_ascii_lowercase().as_str()),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn manager(credential: &str, base_path: &str) -> TokenManager {
TokenManager::new(credential, reqwest::Client::new(), base_path)
}
use crate::ENV_LOCK;
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
fn set(value: &str) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var(DISABLE_ENV, value);
EnvGuard { _lock: lock }
}
fn unset() -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var(DISABLE_ENV);
EnvGuard { _lock: lock }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var(DISABLE_ENV);
}
}
#[test]
fn jwt_credential_is_passed_through() {
let _g = EnvGuard::unset();
let m = manager("eyJhbGciOiJIUzI1NiJ9.payload.sig", "http://127.0.0.1:1");
assert!(
!m.needs_exchange(),
"literal JWTs must pass through unchanged"
);
}
#[test]
fn opaque_token_needs_exchange() {
let _g = EnvGuard::unset();
assert!(manager("hd_deadbeef", "http://127.0.0.1:1").needs_exchange());
assert!(manager("deadbeefcafef00d", "http://127.0.0.1:1").needs_exchange());
}
#[test]
fn affirmative_optout_values_disable_exchange() {
for v in ["1", "true", "TRUE", "Yes", " on ", "On"] {
let _g = EnvGuard::set(v);
assert!(
!manager("hd_opaque", "http://127.0.0.1:1").needs_exchange(),
"value {v:?} should disable exchange"
);
}
}
#[test]
fn non_affirmative_optout_values_keep_exchange() {
for v in ["0", "false", "no", "off", "", " ", "maybe", "2"] {
let _g = EnvGuard::set(v);
assert!(
manager("hd_opaque", "http://127.0.0.1:1").needs_exchange(),
"value {v:?} must NOT disable exchange"
);
}
}
#[tokio::test]
async fn optout_returns_opaque_credential_unchanged() {
let _g = EnvGuard::set("1");
let m = manager("hd_opaque", "http://127.0.0.1:1");
assert_eq!(m.bearer_value().await.unwrap(), "hd_opaque");
}
#[tokio::test]
async fn passthrough_returns_jwt_without_network() {
let _g = EnvGuard::unset();
let m = manager("eyJ.a.b", "http://127.0.0.1:1");
assert_eq!(m.bearer_value().await.unwrap(), "eyJ.a.b");
}
#[test]
fn apply_uses_default_expiry_when_missing() {
let mut state = TokenState::default();
let before = now_unix();
TokenManager::apply(
&mut state,
TokenResponse {
access_token: "jwt".into(),
expires_in: None,
refresh_token: None,
},
);
assert_eq!(state.jwt.as_deref(), Some("jwt"));
let ttl = state.exp - before;
assert!(
(DEFAULT_EXPIRES_IN..=DEFAULT_EXPIRES_IN + 5).contains(&ttl),
"ttl={ttl}"
);
}
#[test]
fn apply_carries_refresh_token_forward_when_omitted() {
let mut state = TokenState {
refresh: Some("old-refresh".into()),
..Default::default()
};
TokenManager::apply(
&mut state,
TokenResponse {
access_token: "jwt".into(),
expires_in: Some(300),
refresh_token: None,
},
);
assert_eq!(state.refresh.as_deref(), Some("old-refresh"));
}
#[test]
fn apply_uses_rotated_refresh_token_when_present() {
let mut state = TokenState {
refresh: Some("old".into()),
..Default::default()
};
TokenManager::apply(
&mut state,
TokenResponse {
access_token: "jwt".into(),
expires_in: Some(300),
refresh_token: Some("rotated".into()),
},
);
assert_eq!(state.refresh.as_deref(), Some("rotated"));
}
#[tokio::test]
async fn fast_path_returns_cached_jwt_without_network() {
let _g = EnvGuard::unset();
let m = manager("hd_opaque", "http://127.0.0.1:1");
{
let mut state = m.state.lock().await;
state.jwt = Some("cached-jwt".into());
state.exp = now_unix() + 600; }
assert_eq!(m.bearer_value().await.unwrap(), "cached-jwt");
}
#[tokio::test]
async fn cached_jwt_inside_leeway_is_not_used_directly() {
let _g = EnvGuard::unset();
let m = manager("hd_opaque", "http://127.0.0.1:1");
{
let mut state = m.state.lock().await;
state.jwt = Some("stale-jwt".into());
state.exp = now_unix() + 5; }
let err = m.bearer_value().await.unwrap_err();
assert!(
matches!(err, TokenExchangeError::Transport(_)),
"expected a transport error from the re-mint attempt, got {err:?}"
);
}
#[tokio::test]
async fn mints_from_api_token_when_cache_empty() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.and(body_string_contains("grant_type=api_token"))
.and(body_string_contains("client_id=hotdata-rust-sdk"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "minted-jwt",
"expires_in": 300,
"refresh_token": "r1"
})))
.mount(&server)
.await;
let m = manager("hd_opaque", &server.uri());
assert_eq!(m.bearer_value().await.unwrap(), "minted-jwt");
assert_eq!(m.bearer_value().await.unwrap(), "minted-jwt");
assert_eq!(m.state.lock().await.refresh.as_deref(), Some("r1"));
}
#[tokio::test]
async fn refresh_failure_falls_through_to_remint() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.and(body_string_contains("grant_type=refresh_token"))
.respond_with(ResponseTemplate::new(400).set_body_string("invalid_grant"))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.and(body_string_contains("grant_type=api_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "reminted-jwt",
"expires_in": 300
})))
.mount(&server)
.await;
let m = manager("hd_opaque", &server.uri());
{
let mut state = m.state.lock().await;
state.jwt = Some("expired".into());
state.exp = now_unix(); state.refresh = Some("dead-refresh".into());
}
assert_eq!(m.bearer_value().await.unwrap(), "reminted-jwt");
assert_eq!(m.state.lock().await.refresh, None);
}
#[tokio::test]
async fn http_error_on_api_token_mint_is_surfaced() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid api token"))
.mount(&server)
.await;
let m = manager("revoked", &server.uri());
let err = m.bearer_value().await.unwrap_err();
match err {
TokenExchangeError::Status { status, body } => {
assert_eq!(status, 401);
assert!(body.contains("invalid api token"), "body={body}");
}
other => panic!("expected Status error, got {other:?}"),
}
}
#[tokio::test]
async fn malformed_response_is_surfaced() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let m = manager("hd_opaque", &server.uri());
assert!(matches!(
m.bearer_value().await.unwrap_err(),
TokenExchangeError::Malformed(_)
));
}
#[tokio::test]
async fn single_flight_mints_once_under_concurrency() {
use std::sync::atomic::{AtomicUsize, Ordering};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
struct Counter(Arc<AtomicUsize>);
impl Respond for Counter {
fn respond(&self, _: &Request) -> ResponseTemplate {
self.0.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "minted-jwt",
"expires_in": 300
}))
}
}
let hits = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.respond_with(Counter(hits.clone()))
.mount(&server)
.await;
let m = Arc::new(manager("hd_opaque", &server.uri()));
let mut handles = Vec::new();
for _ in 0..16 {
let m = m.clone();
handles.push(tokio::spawn(async move { m.bearer_value().await }));
}
for h in handles {
assert_eq!(h.await.unwrap().unwrap(), "minted-jwt");
}
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"single-flight must mint once"
);
}
#[tokio::test]
async fn trailing_slash_in_base_path_is_normalized() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "ok",
"expires_in": 300
})))
.mount(&server)
.await;
let base = format!("{}/", server.uri());
let m = manager("hd_opaque", &base);
assert_eq!(m.bearer_value().await.unwrap(), "ok");
}
#[test]
fn new_matches_default_options() {
let m = TokenManager::new("hd_opaque", reqwest::Client::new(), "https://api.example.dev");
assert_eq!(m.client_id, CLIENT_ID);
assert_eq!(m.token_path, "/v1/auth/jwt");
assert_eq!(m.base_path, "https://api.example.dev");
assert!(m.on_persist.is_none());
}
#[test]
fn options_default_preserves_legacy_attribution() {
let opts = TokenManagerOptions::default();
assert_eq!(opts.client_id, CLIENT_ID);
assert_eq!(opts.token_path, "/v1/auth/jwt");
assert!(opts.base_path.is_empty());
assert!(opts.seed_refresh.is_none());
assert!(opts.seed_jwt.is_none());
assert!(opts.on_persist.is_none());
}
#[tokio::test]
async fn seed_jwt_is_served_without_minting() {
let _g = EnvGuard::unset();
let m = TokenManager::with_options(
"hd_opaque",
reqwest::Client::new(),
TokenManagerOptions {
base_path: "http://127.0.0.1:1".into(),
seed_jwt: Some(("seeded-jwt".into(), now_unix() + 600)),
..Default::default()
},
);
assert_eq!(m.bearer_value().await.unwrap(), "seeded-jwt");
}
#[tokio::test]
async fn seed_refresh_drives_refresh_grant_first() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=seeded-refresh"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "from-seeded-refresh",
"expires_in": 300
})))
.mount(&server)
.await;
let m = TokenManager::with_options(
"hd_opaque",
reqwest::Client::new(),
TokenManagerOptions {
base_path: server.uri(),
seed_refresh: Some("seeded-refresh".into()),
..Default::default()
},
);
assert_eq!(m.bearer_value().await.unwrap(), "from-seeded-refresh");
}
#[tokio::test]
async fn configurable_client_id_and_token_path_hit_the_wire() {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/o/token/"))
.and(body_string_contains("client_id=hotdata-cli"))
.and(body_string_contains("grant_type=api_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "cli-minted",
"expires_in": 300
})))
.mount(&server)
.await;
let m = TokenManager::with_options(
"hd_opaque",
reqwest::Client::new(),
TokenManagerOptions {
base_path: server.uri(),
client_id: "hotdata-cli".into(),
token_path: "/o/token/".into(),
..Default::default()
},
);
assert_eq!(m.bearer_value().await.unwrap(), "cli-minted");
}
#[tokio::test]
async fn on_persist_fires_on_mint_with_rotated_tokens() {
use std::sync::Mutex as StdMutex;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "minted-jwt",
"expires_in": 300,
"refresh_token": "rotated-refresh"
})))
.mount(&server)
.await;
let captured: Arc<StdMutex<Option<(String, Option<String>, u64)>>> =
Arc::new(StdMutex::new(None));
let sink = captured.clone();
let cb: PersistCallback = Arc::new(move |jwt: &str, refresh: Option<&str>, exp: u64| {
*sink.lock().unwrap() = Some((jwt.to_string(), refresh.map(str::to_string), exp));
});
let before = now_unix();
let m = TokenManager::with_options(
"hd_opaque",
reqwest::Client::new(),
TokenManagerOptions {
base_path: server.uri(),
on_persist: Some(cb),
..Default::default()
},
);
assert_eq!(m.bearer_value().await.unwrap(), "minted-jwt");
let got = captured.lock().unwrap().clone().expect("on_persist must fire");
assert_eq!(got.0, "minted-jwt");
assert_eq!(got.1.as_deref(), Some("rotated-refresh"));
assert!(got.2 >= before + 300, "exp should be ~now+expires_in");
}
#[tokio::test]
async fn on_persist_carries_refresh_forward_when_server_omits_it() {
use std::sync::Mutex as StdMutex;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _g = EnvGuard::unset();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/jwt"))
.and(body_string_contains("grant_type=refresh_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "refreshed-jwt",
"expires_in": 300
})))
.mount(&server)
.await;
let captured: Arc<StdMutex<Option<Option<String>>>> = Arc::new(StdMutex::new(None));
let sink = captured.clone();
let cb: PersistCallback = Arc::new(move |_jwt: &str, refresh: Option<&str>, _exp: u64| {
*sink.lock().unwrap() = Some(refresh.map(str::to_string));
});
let m = TokenManager::with_options(
"hd_opaque",
reqwest::Client::new(),
TokenManagerOptions {
base_path: server.uri(),
seed_refresh: Some("seeded".into()),
on_persist: Some(cb),
..Default::default()
},
);
assert_eq!(m.bearer_value().await.unwrap(), "refreshed-jwt");
assert_eq!(captured.lock().unwrap().clone(), Some(Some("seeded".to_string())));
assert_eq!(m.state.lock().await.refresh.as_deref(), Some("seeded"));
}
#[tokio::test]
async fn on_persist_does_not_fire_for_seeded_jwt_fast_path() {
use std::sync::atomic::{AtomicUsize, Ordering};
let _g = EnvGuard::unset();
let calls = Arc::new(AtomicUsize::new(0));
let sink = calls.clone();
let cb: PersistCallback = Arc::new(move |_: &str, _: Option<&str>, _: u64| {
sink.fetch_add(1, Ordering::SeqCst);
});
let m = TokenManager::with_options(
"hd_opaque",
reqwest::Client::new(),
TokenManagerOptions {
base_path: "http://127.0.0.1:1".into(),
seed_jwt: Some(("seeded".into(), now_unix() + 600)),
on_persist: Some(cb),
..Default::default()
},
);
assert_eq!(m.bearer_value().await.unwrap(), "seeded");
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"no mint occurred, so on_persist must not fire"
);
}
}