use std::time::Duration;
use async_trait::async_trait;
use ppoppo_sdk_core::token_cache::{TokenCacheError, TokenSource};
use crate::pas_port::{PasAuthPort, PasFailure};
#[derive(Debug, thiserror::Error)]
#[error("token store error: {0}")]
pub struct TokenStoreError(String);
impl TokenStoreError {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
#[async_trait]
pub trait TokenStore: Send + Sync {
async fn load(&self) -> Result<Option<String>, TokenStoreError>;
async fn save(&self, refresh_token: &str) -> Result<(), TokenStoreError>;
async fn clear(&self) -> Result<(), TokenStoreError>;
}
#[derive(Debug, Default)]
pub struct MemoryTokenStore {
inner: std::sync::Mutex<Option<String>>,
}
impl MemoryTokenStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_token(refresh_token: impl Into<String>) -> Self {
Self { inner: std::sync::Mutex::new(Some(refresh_token.into())) }
}
}
#[async_trait]
impl TokenStore for MemoryTokenStore {
async fn load(&self) -> Result<Option<String>, TokenStoreError> {
let guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
Ok(guard.clone())
}
async fn save(&self, refresh_token: &str) -> Result<(), TokenStoreError> {
let mut guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
*guard = Some(refresh_token.to_owned());
Ok(())
}
async fn clear(&self) -> Result<(), TokenStoreError> {
let mut guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
*guard = None;
Ok(())
}
}
pub struct RefreshTokenSource<P: PasAuthPort, S: TokenStore> {
auth: P,
store: S,
}
impl<P: PasAuthPort, S: TokenStore> RefreshTokenSource<P, S> {
pub fn new(auth: P, store: S) -> Self {
Self { auth, store }
}
}
#[async_trait]
impl<P: PasAuthPort, S: TokenStore> TokenSource for RefreshTokenSource<P, S> {
async fn fetch_token(&self) -> Result<(String, Duration), TokenCacheError> {
let refresh_token = self
.store
.load()
.await
.map_err(|e| TokenCacheError::Source(Box::new(e)))?
.ok_or_else(|| {
TokenCacheError::Source(Box::new(TokenStoreError::new(
"no refresh token stored — client is not authenticated",
)))
})?;
match self.auth.refresh(&refresh_token).await {
Ok(response) => {
if let Some(rotated) = response.refresh_token.as_deref() {
self.store
.save(rotated)
.await
.map_err(|e| TokenCacheError::Source(Box::new(e)))?;
}
let ttl = Duration::from_secs(response.expires_in.unwrap_or(3600));
Ok((response.access_token, ttl))
}
Err(PasFailure::Rejected { detail, .. }) => {
let _ = self.store.clear().await;
Err(TokenCacheError::Fetch(format!(
"refresh rejected (credential dead): {detail}"
)))
}
Err(PasFailure::ServerError { detail, .. }) => {
Err(TokenCacheError::Fetch(format!("PAS refresh 5xx: {detail}")))
}
Err(PasFailure::Transport { detail }) => {
Err(TokenCacheError::Fetch(format!("PAS refresh transport error: {detail}")))
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::oauth::TokenResponse;
use std::sync::Mutex;
struct MockAuth {
outcome: Mutex<Result<TokenResponse, PasFailure>>,
seen: Mutex<Vec<String>>,
}
impl MockAuth {
fn ok(access: &str, rotated: Option<&str>, expires_in: Option<u64>) -> Self {
Self {
outcome: Mutex::new(Ok(TokenResponse {
access_token: access.to_owned(),
token_type: "Bearer".to_owned(),
expires_in,
refresh_token: rotated.map(str::to_owned),
id_token: None,
})),
seen: Mutex::new(Vec::new()),
}
}
fn rejected() -> Self {
Self {
outcome: Mutex::new(Err(PasFailure::Rejected {
status: 400,
detail: "invalid_grant".to_owned(),
})),
seen: Mutex::new(Vec::new()),
}
}
}
impl PasAuthPort for MockAuth {
fn refresh(
&self,
refresh_token: &str,
) -> impl std::future::Future<Output = Result<TokenResponse, PasFailure>> + Send {
let token = refresh_token.to_owned();
async move {
self.seen.lock().unwrap().push(token);
self.outcome.lock().unwrap().clone()
}
}
}
#[tokio::test]
async fn rotation_is_persisted_to_the_store() {
let auth = MockAuth::ok("AT-1", Some("RT-2"), Some(900));
let store = MemoryTokenStore::with_token("RT-1");
let source = RefreshTokenSource::new(auth, store);
let (access, ttl) = source.fetch_token().await.expect("fetch");
assert_eq!(access, "AT-1");
assert_eq!(ttl, Duration::from_secs(900));
assert_eq!(source.auth.seen.lock().unwrap().as_slice(), ["RT-1"]);
assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-2"));
let _ = source.fetch_token().await.expect("second fetch");
assert_eq!(source.auth.seen.lock().unwrap().as_slice(), ["RT-1", "RT-2"]);
}
#[tokio::test]
async fn absent_rotation_keeps_the_current_token() {
let auth = MockAuth::ok("AT-1", None, None);
let store = MemoryTokenStore::with_token("RT-1");
let source = RefreshTokenSource::new(auth, store);
let (_, ttl) = source.fetch_token().await.expect("fetch");
assert_eq!(ttl, Duration::from_secs(3600), "missing expires_in defaults to 1h");
assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-1"));
}
#[tokio::test]
async fn missing_credential_is_a_source_error() {
let auth = MockAuth::ok("AT", Some("RT-2"), None);
let source = RefreshTokenSource::new(auth, MemoryTokenStore::new());
let err = source.fetch_token().await.expect_err("empty store must fail");
assert!(matches!(err, TokenCacheError::Source(_)));
}
#[tokio::test]
async fn rejected_refresh_clears_the_store() {
let auth = MockAuth::rejected();
let store = MemoryTokenStore::with_token("RT-dead");
let source = RefreshTokenSource::new(auth, store);
let err = source.fetch_token().await.expect_err("dead credential must fail");
assert!(matches!(err, TokenCacheError::Fetch(_)));
assert_eq!(source.store.load().await.unwrap(), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fetch_token_future_is_send() {
let source = RefreshTokenSource::new(
MockAuth::ok("AT", Some("RT-2"), None),
MemoryTokenStore::with_token("RT-1"),
);
let handle = tokio::spawn(async move { source.fetch_token().await });
let (access, _) = handle.await.expect("join").expect("fetch");
assert_eq!(access, "AT");
}
}