use std::marker::PhantomData;
use std::time::Duration;
use async_trait::async_trait;
use ppoppo_sdk_core::scopes::{ConsentScopes, ScopedTokenSource};
use ppoppo_sdk_core::token_cache::{TokenCacheError, TokenSource};
use crate::pas_port::{PasAuthPort, PasFailure};
use crate::scope_grant::ensure_covers;
#[cfg(doc)]
use crate::oauth::AuthClient;
#[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(Default)]
pub struct MemoryTokenStore {
inner: std::sync::Mutex<Option<String>>,
}
impl std::fmt::Debug for MemoryTokenStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let held = self
.inner
.lock()
.map(|g| g.is_some())
.unwrap_or_else(|e| e.into_inner().is_some());
f.debug_struct("MemoryTokenStore")
.field("refresh_token", &if held { "[redacted]" } else { "[none]" })
.finish()
}
}
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, T: TokenStore, Sc: ConsentScopes> {
auth: P,
store: T,
_scope: PhantomData<Sc>,
}
impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> RefreshTokenSource<P, T, Sc> {
pub fn new(auth: P, store: T) -> Self {
Self { auth, store, _scope: PhantomData }
}
}
impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> std::fmt::Debug
for RefreshTokenSource<P, T, Sc>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RefreshTokenSource")
.field("scope", &Sc::scope_line())
.finish_non_exhaustive()
}
}
impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> ScopedTokenSource<Sc>
for RefreshTokenSource<P, T, Sc>
{
}
#[async_trait]
impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> TokenSource
for RefreshTokenSource<P, T, Sc>
{
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) => {
ensure_covers::<Sc>(response.scope.as_deref())
.map_err(|e| TokenCacheError::Fetch(format!("refresh {e}")))?;
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 Notify;
impl ConsentScopes for Notify {
const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
}
fn source<T: TokenStore>(auth: MockAuth, store: T) -> RefreshTokenSource<MockAuth, T, Notify> {
RefreshTokenSource::new(auth, store)
}
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::with_scope(access, rotated, expires_in, None)
}
fn with_scope(
access: &str,
rotated: Option<&str>,
expires_in: Option<u64>,
scope: Option<&str>,
) -> 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,
scope: scope.map(str::to_owned),
})),
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 = source(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 = source(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 = source(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 = source(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]
async fn narrowed_refresh_is_a_terminal_error_naming_the_lost_atom() {
let auth = MockAuth::with_scope("AT-1", Some("RT-2"), None, Some("chat.read contact.read"));
let store = MemoryTokenStore::with_token("RT-1");
let source = source(auth, store);
let err = source.fetch_token().await.expect_err("a narrowed grant must not pass");
assert!(matches!(err, TokenCacheError::Fetch(_)));
assert!(err.to_string().contains("chat.ack"), "must name the lost atom: {err}");
assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-1"));
}
#[tokio::test]
async fn a_failed_grant_check_does_not_advance_the_stored_token() {
let auth = MockAuth::with_scope("AT-1", Some("RT-2"), None, Some("chat.read"));
let source = source(auth, MemoryTokenStore::with_token("RT-1"));
let _ = source.fetch_token().await.expect_err("narrowed grant");
assert_eq!(
source.store.load().await.unwrap().as_deref(),
Some("RT-1"),
"rotation must not be persisted past a failed grant check"
);
}
#[tokio::test]
async fn absent_scope_echo_passes_the_grant_check() {
let source = source(
MockAuth::ok("AT-1", Some("RT-2"), None),
MemoryTokenStore::with_token("RT-1"),
);
let (access, _) = source.fetch_token().await.expect("absent echo == granted as requested");
assert_eq!(access, "AT-1");
}
#[tokio::test]
async fn superset_scope_echo_passes_the_grant_check() {
let auth = MockAuth::with_scope(
"AT-1",
None,
None,
Some("chat.read contact.read chat.ack chat.send"),
);
let source = source(auth, MemoryTokenStore::with_token("RT-1"));
assert!(source.fetch_token().await.is_ok());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fetch_token_future_is_send() {
let source = source(
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");
}
}