use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration as StdDuration;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{Duration, Utc};
use paladin_ports::output::auth_port::{AuthClaims, AuthError, AuthPort, AuthToken};
use rand::RngCore;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::core::platform::container::user::UserRole;
const DEFAULT_TTL: StdDuration = StdDuration::from_secs(24 * 60 * 60);
pub struct InMemoryTokenAuthAdapter {
store: RwLock<HashMap<String, AuthClaims>>,
ttl: Duration,
}
impl InMemoryTokenAuthAdapter {
pub fn new() -> Self {
Self::with_ttl(DEFAULT_TTL)
}
pub fn with_ttl(ttl: StdDuration) -> Self {
Self {
store: RwLock::new(HashMap::new()),
ttl: Duration::from_std(ttl).unwrap_or_else(|_| Duration::hours(24)),
}
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
digest.iter().map(|b| format!("{:02x}", b)).collect()
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
}
impl Default for InMemoryTokenAuthAdapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl AuthPort for InMemoryTokenAuthAdapter {
async fn issue_token(&self, user_id: Uuid, role: UserRole) -> Result<AuthToken, AuthError> {
let token = Self::generate_token();
let expires_at = Utc::now() + self.ttl;
let claims = AuthClaims {
user_id,
role,
expires_at,
};
let hash = Self::hash_token(&token);
let mut store = self
.store
.write()
.map_err(|e| AuthError::Internal(format!("token store poisoned: {e}")))?;
store.insert(hash, claims);
Ok(AuthToken { token, expires_at })
}
async fn verify_token(&self, token: &str) -> Result<AuthClaims, AuthError> {
if token.is_empty() {
return Err(AuthError::MissingToken);
}
let hash = Self::hash_token(token);
let claims = {
let store = self
.store
.read()
.map_err(|e| AuthError::Internal(format!("token store poisoned: {e}")))?;
store.get(&hash).cloned()
};
match claims {
None => Err(AuthError::InvalidToken),
Some(claims) if claims.expires_at <= Utc::now() => {
if let Ok(mut store) = self.store.write() {
store.remove(&hash);
}
Err(AuthError::Expired)
}
Some(claims) => Ok(claims),
}
}
async fn revoke_token(&self, token: &str) -> Result<(), AuthError> {
let hash = Self::hash_token(token);
let mut store = self
.store
.write()
.map_err(|e| AuthError::Internal(format!("token store poisoned: {e}")))?;
store.remove(&hash);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issue_then_verify_round_trip() {
let adapter = InMemoryTokenAuthAdapter::new();
let user_id = Uuid::new_v4();
let issued = adapter.issue_token(user_id, UserRole::Admin).await.unwrap();
assert!(!issued.token.is_empty());
assert!(issued.expires_at > Utc::now());
let claims = adapter.verify_token(&issued.token).await.unwrap();
assert_eq!(claims.user_id, user_id);
assert_eq!(claims.role, UserRole::Admin);
}
#[tokio::test]
async fn unknown_token_is_invalid() {
let adapter = InMemoryTokenAuthAdapter::new();
let err = adapter.verify_token("not-a-real-token").await.unwrap_err();
assert!(matches!(err, AuthError::InvalidToken));
}
#[tokio::test]
async fn empty_token_is_missing() {
let adapter = InMemoryTokenAuthAdapter::new();
let err = adapter.verify_token("").await.unwrap_err();
assert!(matches!(err, AuthError::MissingToken));
}
#[tokio::test]
async fn revoked_token_is_invalid() {
let adapter = InMemoryTokenAuthAdapter::new();
let issued = adapter
.issue_token(Uuid::new_v4(), UserRole::User)
.await
.unwrap();
adapter.revoke_token(&issued.token).await.unwrap();
let err = adapter.verify_token(&issued.token).await.unwrap_err();
assert!(matches!(err, AuthError::InvalidToken));
}
#[tokio::test]
async fn expired_token_is_rejected() {
let adapter = InMemoryTokenAuthAdapter::with_ttl(StdDuration::from_secs(0));
let issued = adapter
.issue_token(Uuid::new_v4(), UserRole::User)
.await
.unwrap();
let err = adapter.verify_token(&issued.token).await.unwrap_err();
assert!(matches!(err, AuthError::Expired));
}
}