use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;
use uuid::Uuid;
use crate::RepositoryError;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub user_id: Uuid,
pub subject: String,
pub email: Option<String>,
pub name: Option<String>,
pub roles: Vec<String>,
}
impl Principal {
pub fn new(user_id: Uuid, subject: impl Into<String>) -> Self {
Self {
user_id,
subject: subject.into(),
email: None,
name: None,
roles: Vec::new(),
}
}
pub fn with_user_id(mut self, user_id: Uuid) -> Self {
self.user_id = user_id;
self
}
pub fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.roles = roles.into_iter().map(Into::into).collect();
self
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|held| held == role)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum AuthError {
#[error("auth.invalid")]
Invalid,
#[error("auth.expired")]
Expired,
#[error("auth.unavailable")]
Unavailable,
}
#[async_trait]
pub trait Authenticator: Send + Sync {
async fn authenticate(&self, token: &str) -> Result<Principal, AuthError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("auth.hash_failed")]
pub struct HashError;
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &str) -> Result<String, HashError>;
async fn verify(&self, password: &str, hash: Option<String>) -> bool;
}
#[async_trait]
pub trait CredentialStore: Send + Sync {
async fn password_hash(&self, user_id: Uuid) -> Result<Option<String>, RepositoryError>;
async fn set_password_hash(&self, user_id: Uuid, hash: &str) -> Result<(), RepositoryError>;
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub token: String,
pub expires_at: DateTime<Utc>,
}
impl Session {
pub fn new(token: impl Into<String>, expires_at: DateTime<Utc>) -> Self {
Self {
token: token.into(),
expires_at,
}
}
}
#[async_trait]
pub trait SessionStore: Send + Sync {
async fn open(&self, user_id: Uuid) -> Result<Session, RepositoryError>;
async fn revoke(&self, token: &str) -> Result<(), RepositoryError>;
}
#[cfg(any(test, feature = "test-utils"))]
pub mod mocks {
use super::*;
use mockall::mock;
mock! {
pub Authenticator {}
#[async_trait]
impl Authenticator for Authenticator {
async fn authenticate(&self, token: &str) -> Result<Principal, AuthError>;
}
}
mock! {
pub PasswordHasher {}
#[async_trait]
impl PasswordHasher for PasswordHasher {
async fn hash(&self, password: &str) -> Result<String, HashError>;
async fn verify(&self, password: &str, hash: Option<String>) -> bool;
}
}
mock! {
pub CredentialStore {}
#[async_trait]
impl CredentialStore for CredentialStore {
async fn password_hash(&self, user_id: Uuid) -> Result<Option<String>, RepositoryError>;
async fn set_password_hash(&self, user_id: Uuid, hash: &str) -> Result<(), RepositoryError>;
}
}
mock! {
pub SessionStore {}
#[async_trait]
impl SessionStore for SessionStore {
async fn open(&self, user_id: Uuid) -> Result<Session, RepositoryError>;
async fn revoke(&self, token: &str) -> Result<(), RepositoryError>;
}
}
}
#[cfg(test)]
mod tests {
use super::mocks::{
MockAuthenticator, MockCredentialStore, MockPasswordHasher, MockSessionStore,
};
use super::*;
#[test]
fn should_hold_no_role_or_profile_when_built_from_an_id_and_a_subject() {
let user_id = Uuid::now_v7();
let principal = Principal::new(user_id, "auth0|ada");
assert_eq!(principal.user_id, user_id);
assert_eq!(principal.subject, "auth0|ada");
assert_eq!(principal.email, None);
assert_eq!(principal.name, None);
assert!(principal.roles.is_empty());
}
#[test]
fn should_carry_the_profile_when_given_one() {
let user_id = Uuid::now_v7();
let principal = Principal::new(Uuid::nil(), "ada")
.with_user_id(user_id)
.with_email("ada@example.com")
.with_name("Ada Lovelace")
.with_roles(["admin", "editor"]);
assert_eq!(principal.user_id, user_id);
assert_eq!(principal.email.as_deref(), Some("ada@example.com"));
assert_eq!(principal.name.as_deref(), Some("Ada Lovelace"));
assert_eq!(principal.roles, vec!["admin", "editor"]);
}
#[test]
fn should_have_a_role_only_when_it_holds_exactly_that_role() {
let principal = Principal::new(Uuid::nil(), "ada").with_roles(["admin"]);
assert!(principal.has_role("admin"));
assert!(!principal.has_role("Admin"));
assert!(!principal.has_role("adm"));
assert!(!principal.has_role("editor"));
}
#[test]
fn should_keep_error_codes_stable_when_logged() {
assert_eq!(AuthError::Invalid.to_string(), "auth.invalid");
assert_eq!(AuthError::Expired.to_string(), "auth.expired");
assert_eq!(AuthError::Unavailable.to_string(), "auth.unavailable");
}
#[test]
fn should_keep_the_token_and_its_expiry_when_a_session_is_built() {
let expires_at = chrono::Utc::now();
let session = Session::new("opaque", expires_at);
assert_eq!(session.token, "opaque");
assert_eq!(session.expires_at, expires_at);
assert_eq!(HashError.to_string(), "auth.hash_failed");
}
#[tokio::test]
async fn should_drive_the_local_ports_when_mocked() {
let user_id = Uuid::now_v7();
let mut hasher = MockPasswordHasher::new();
hasher
.expect_verify()
.returning(|password, hash| hash.as_deref() == Some("h") && password == "secret");
let mut credentials = MockCredentialStore::new();
credentials
.expect_password_hash()
.returning(|_| Ok(Some("h".to_string())));
let mut sessions = MockSessionStore::new();
sessions
.expect_open()
.returning(|_| Ok(Session::new("t", chrono::Utc::now())));
let (hasher, credentials, sessions): (
&dyn PasswordHasher,
&dyn CredentialStore,
&dyn SessionStore,
) = (&hasher, &credentials, &sessions);
let hash = credentials.password_hash(user_id).await;
let accepted = hasher.verify("secret", Some("h".to_string())).await;
let unknown = hasher.verify("secret", None).await;
let session = sessions.open(user_id).await;
assert_eq!(hash, Ok(Some("h".to_string())));
assert!(accepted);
assert!(!unknown);
assert_eq!(session.map(|s| s.token), Ok("t".to_string()));
}
#[tokio::test]
async fn should_answer_what_the_test_programs_when_mocked() {
let mut authenticator = MockAuthenticator::new();
authenticator
.expect_authenticate()
.returning(|token| match token {
"good" => Ok(Principal::new(Uuid::nil(), "ada")),
_ => Err(AuthError::Expired),
});
let authenticator: &dyn Authenticator = &authenticator;
let accepted = authenticator.authenticate("good").await;
let rejected = authenticator.authenticate("old").await;
assert_eq!(accepted.map(|p| p.subject), Ok("ada".to_string()));
assert_eq!(rejected, Err(AuthError::Expired));
}
}