use std::fmt;
use crate::session::Session;
use crate::util::timestamp::Timestamp;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[doc(alias = "provider")]
pub enum AuthProviderKind {
Local,
OAuth,
Oidc,
Saml,
ApiKey,
Hmac,
Bearer,
}
impl fmt::Display for AuthProviderKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Local => "local",
Self::OAuth => "oauth",
Self::Oidc => "oidc",
Self::Saml => "saml",
Self::ApiKey => "api_key",
Self::Hmac => "hmac",
Self::Bearer => "bearer",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[doc(alias = "identity")]
pub struct AuthIdentity {
subject: String,
email: Option<String>,
display_name: Option<String>,
claims: Vec<(String, String)>,
}
impl AuthIdentity {
#[must_use]
pub fn new(subject: impl Into<String>) -> Self {
Self {
subject: subject.into(),
email: None,
display_name: None,
claims: Vec::new(),
}
}
#[must_use]
pub fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
#[must_use]
pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
self.display_name = Some(name.into());
self
}
#[must_use]
pub fn with_claim(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.claims.push((key.into(), value.into()));
self
}
#[must_use]
#[inline]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
#[inline]
pub fn email(&self) -> Option<&str> {
self.email.as_deref()
}
#[must_use]
#[inline]
pub fn display_name(&self) -> Option<&str> {
self.display_name.as_deref()
}
#[must_use]
#[inline]
pub fn claims(&self) -> &[(String, String)] {
&self.claims
}
#[must_use]
#[inline]
pub fn get_claim(&self, key: &str) -> Option<&str> {
self.claims
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Clone)]
#[doc(alias = "auth_result")]
pub struct AuthResult {
identity: AuthIdentity,
provider: AuthProviderKind,
authenticated_at: Timestamp,
session: Option<Session>,
}
impl AuthResult {
#[must_use]
pub fn new(
identity: AuthIdentity,
provider: AuthProviderKind,
authenticated_at: Timestamp,
) -> Self {
Self {
identity,
provider,
authenticated_at,
session: None,
}
}
#[must_use]
pub fn with_session(mut self, session: Session) -> Self {
self.session = Some(session);
self
}
#[must_use]
#[inline]
pub fn identity(&self) -> &AuthIdentity {
&self.identity
}
#[must_use]
#[inline]
pub fn provider(&self) -> AuthProviderKind {
self.provider
}
#[must_use]
#[inline]
pub fn authenticated_at(&self) -> &Timestamp {
&self.authenticated_at
}
#[must_use]
#[inline]
pub fn session(&self) -> Option<&Session> {
self.session.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AuthErrorKind {
InvalidCredentials,
TokenExpired,
InvalidToken(String),
InvalidConfiguration(String),
Crypto(String),
SessionExpired,
Provider(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[doc(alias = "auth_error")]
pub struct AuthError {
kind: AuthErrorKind,
}
#[allow(dead_code)]
impl AuthError {
const fn new(kind: AuthErrorKind) -> Self {
Self { kind }
}
pub(crate) fn invalid_credentials() -> Self {
Self::new(AuthErrorKind::InvalidCredentials)
}
pub(crate) fn token_expired() -> Self {
Self::new(AuthErrorKind::TokenExpired)
}
pub(crate) fn invalid_token(detail: impl Into<String>) -> Self {
Self::new(AuthErrorKind::InvalidToken(detail.into()))
}
pub(crate) fn invalid_configuration(detail: impl Into<String>) -> Self {
Self::new(AuthErrorKind::InvalidConfiguration(detail.into()))
}
pub(crate) fn crypto(detail: impl Into<String>) -> Self {
Self::new(AuthErrorKind::Crypto(detail.into()))
}
pub(crate) fn session_expired() -> Self {
Self::new(AuthErrorKind::SessionExpired)
}
pub(crate) fn provider(detail: impl Into<String>) -> Self {
Self::new(AuthErrorKind::Provider(detail.into()))
}
}
impl AuthError {
#[must_use]
#[inline]
pub fn is_invalid_credentials(&self) -> bool {
self.kind == AuthErrorKind::InvalidCredentials
}
#[must_use]
#[inline]
pub fn is_token_expired(&self) -> bool {
self.kind == AuthErrorKind::TokenExpired
}
#[must_use]
#[inline]
pub fn is_invalid_token(&self) -> bool {
matches!(self.kind, AuthErrorKind::InvalidToken(_))
}
#[must_use]
#[inline]
pub fn is_invalid_configuration(&self) -> bool {
matches!(self.kind, AuthErrorKind::InvalidConfiguration(_))
}
#[must_use]
#[inline]
pub fn is_crypto(&self) -> bool {
matches!(self.kind, AuthErrorKind::Crypto(_))
}
#[must_use]
#[inline]
pub fn is_session_expired(&self) -> bool {
self.kind == AuthErrorKind::SessionExpired
}
#[must_use]
#[inline]
pub fn is_provider(&self) -> bool {
matches!(self.kind, AuthErrorKind::Provider(_))
}
#[must_use]
#[inline]
pub fn detail(&self) -> Option<&str> {
match &self.kind {
AuthErrorKind::InvalidToken(d)
| AuthErrorKind::InvalidConfiguration(d)
| AuthErrorKind::Crypto(d)
| AuthErrorKind::Provider(d) => Some(d),
AuthErrorKind::InvalidCredentials
| AuthErrorKind::TokenExpired
| AuthErrorKind::SessionExpired => None,
}
}
}
impl fmt::Display for AuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
AuthErrorKind::InvalidCredentials => f.write_str("authentication failed"),
AuthErrorKind::TokenExpired => f.write_str("token expired"),
AuthErrorKind::InvalidToken(detail) => {
f.write_str("invalid token: ")?;
f.write_str(detail)
}
AuthErrorKind::InvalidConfiguration(detail) => {
f.write_str("invalid configuration: ")?;
f.write_str(detail)
}
AuthErrorKind::Crypto(detail) => {
f.write_str("crypto error: ")?;
f.write_str(detail)
}
AuthErrorKind::SessionExpired => f.write_str("session expired"),
AuthErrorKind::Provider(detail) => {
f.write_str("provider error: ")?;
f.write_str(detail)
}
}
}
}
impl std::error::Error for AuthError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_kind_display_local() {
assert_eq!(AuthProviderKind::Local.to_string(), "local");
}
#[test]
fn provider_kind_display_oauth() {
assert_eq!(AuthProviderKind::OAuth.to_string(), "oauth");
}
#[test]
fn provider_kind_display_oidc() {
assert_eq!(AuthProviderKind::Oidc.to_string(), "oidc");
}
#[test]
fn provider_kind_display_saml() {
assert_eq!(AuthProviderKind::Saml.to_string(), "saml");
}
#[test]
fn provider_kind_display_api_key() {
assert_eq!(AuthProviderKind::ApiKey.to_string(), "api_key");
}
#[test]
fn provider_kind_display_hmac() {
assert_eq!(AuthProviderKind::Hmac.to_string(), "hmac");
}
#[test]
fn provider_kind_display_bearer() {
assert_eq!(AuthProviderKind::Bearer.to_string(), "bearer");
}
#[test]
fn provider_kind_clone() {
let original = AuthProviderKind::OAuth;
let cloned = original;
assert_eq!(original, cloned);
}
#[test]
fn provider_kind_copy() {
let a = AuthProviderKind::Oidc;
let b = a;
assert_eq!(a, b);
}
#[test]
fn provider_kind_partial_eq_different_variants() {
assert_ne!(AuthProviderKind::Local, AuthProviderKind::OAuth);
assert_ne!(AuthProviderKind::Hmac, AuthProviderKind::Bearer);
}
#[test]
fn identity_new_creates_with_subject_only() {
let id = AuthIdentity::new("user-42");
assert_eq!(id.subject(), "user-42");
assert_eq!(id.email(), None);
assert_eq!(id.display_name(), None);
assert!(id.claims().is_empty());
}
#[test]
fn identity_builder_methods_chain() {
let id = AuthIdentity::new("sub-1")
.with_email("alice@example.com")
.with_display_name("Alice")
.with_claim("role", "admin")
.with_claim("org", "entropy");
assert_eq!(id.subject(), "sub-1");
assert_eq!(id.email(), Some("alice@example.com"));
assert_eq!(id.display_name(), Some("Alice"));
assert_eq!(id.claims().len(), 2);
}
#[test]
fn identity_accessors_return_correct_values() {
let id = AuthIdentity::new("sub-99")
.with_email("bob@test.io")
.with_display_name("Bob")
.with_claim("tier", "free");
assert_eq!(id.subject(), "sub-99");
assert_eq!(id.email(), Some("bob@test.io"));
assert_eq!(id.display_name(), Some("Bob"));
assert_eq!(id.claims(), &[("tier".to_owned(), "free".to_owned())]);
}
#[test]
fn identity_empty_claims_vec() {
let id = AuthIdentity::new("x");
let empty: &[(String, String)] = &[];
assert_eq!(id.claims(), empty);
}
#[test]
fn identity_get_claim_returns_matching_value() {
let id = AuthIdentity::new("sub-1")
.with_claim("role", "admin")
.with_claim("org", "entropy");
assert_eq!(id.get_claim("role"), Some("admin"));
assert_eq!(id.get_claim("org"), Some("entropy"));
}
#[test]
fn identity_get_claim_returns_none_for_missing_key() {
let id = AuthIdentity::new("sub-1").with_claim("role", "admin");
assert_eq!(id.get_claim("missing"), None);
}
#[test]
fn identity_get_claim_returns_none_when_no_claims() {
let id = AuthIdentity::new("sub-1");
assert_eq!(id.get_claim("anything"), None);
}
#[test]
fn result_new_without_session() {
let identity = AuthIdentity::new("user-1");
let ts = Timestamp::from_unix_secs(1_700_000_000);
let result = AuthResult::new(identity, AuthProviderKind::Local, ts);
assert_eq!(result.identity().subject(), "user-1");
assert_eq!(result.provider(), AuthProviderKind::Local);
assert_eq!(result.authenticated_at().unix_epoch_secs(), 1_700_000_000);
assert!(result.session().is_none());
}
#[test]
fn result_with_session() {
let identity = AuthIdentity::new("user-2");
let ts = Timestamp::from_unix_secs(1_700_000_000);
let config = crate::session::SessionConfig::default();
let (_, session) = Session::create(&config).unwrap();
let result = AuthResult::new(identity, AuthProviderKind::Bearer, ts).with_session(session);
assert!(result.session().is_some());
}
#[test]
fn result_accessors() {
let identity = AuthIdentity::new("sub-abc").with_email("c@d.com");
let ts = Timestamp::from_unix_secs(1_234_567_890);
let result = AuthResult::new(identity, AuthProviderKind::Hmac, ts);
assert_eq!(result.identity().email(), Some("c@d.com"));
assert_eq!(result.provider(), AuthProviderKind::Hmac);
assert_eq!(result.authenticated_at().unix_epoch_secs(), 1_234_567_890);
assert!(result.session().is_none());
}
#[test]
fn error_display_invalid_credentials_is_vague() {
let err = AuthError::invalid_credentials();
assert_eq!(err.to_string(), "authentication failed");
assert!(err.is_invalid_credentials());
assert_eq!(err.detail(), None);
}
#[test]
fn error_display_token_expired() {
let err = AuthError::token_expired();
assert_eq!(err.to_string(), "token expired");
assert!(err.is_token_expired());
assert_eq!(err.detail(), None);
}
#[test]
fn error_display_invalid_token() {
let err = AuthError::invalid_token("bad signature");
assert_eq!(err.to_string(), "invalid token: bad signature");
assert!(err.is_invalid_token());
assert_eq!(err.detail(), Some("bad signature"));
}
#[test]
fn error_display_invalid_configuration() {
let err = AuthError::invalid_configuration("missing issuer");
assert_eq!(err.to_string(), "invalid configuration: missing issuer");
assert!(err.is_invalid_configuration());
assert_eq!(err.detail(), Some("missing issuer"));
}
#[test]
fn error_display_crypto() {
let err = AuthError::crypto("hash mismatch");
assert_eq!(err.to_string(), "crypto error: hash mismatch");
assert!(err.is_crypto());
assert_eq!(err.detail(), Some("hash mismatch"));
}
#[test]
fn error_display_session_expired() {
let err = AuthError::session_expired();
assert_eq!(err.to_string(), "session expired");
assert!(err.is_session_expired());
assert_eq!(err.detail(), None);
}
#[test]
fn error_display_provider() {
let err = AuthError::provider("upstream timeout");
assert_eq!(err.to_string(), "provider error: upstream timeout");
assert!(err.is_provider());
assert_eq!(err.detail(), Some("upstream timeout"));
}
#[test]
fn error_debug_does_not_leak_secrets() {
let err = AuthError::invalid_token("test detail");
let debug = format!("{err:?}");
assert!(
debug.contains("InvalidToken"),
"Debug should contain variant name: {debug}",
);
assert!(
debug.contains("test detail"),
"Debug should contain the detail string: {debug}",
);
assert!(
!debug.contains("password"),
"Debug must not mention passwords: {debug}",
);
}
#[test]
fn error_implements_std_error() {
let errors: Vec<Box<dyn std::error::Error>> = vec![
Box::new(AuthError::invalid_credentials()),
Box::new(AuthError::token_expired()),
Box::new(AuthError::invalid_token("x")),
Box::new(AuthError::invalid_configuration("y")),
Box::new(AuthError::crypto("z")),
Box::new(AuthError::session_expired()),
Box::new(AuthError::provider("w")),
];
for err in &errors {
assert!(err.source().is_none(), "source() should be None for: {err}");
}
}
#[test]
fn error_partial_eq() {
assert_eq!(
AuthError::invalid_credentials(),
AuthError::invalid_credentials()
);
assert_eq!(AuthError::token_expired(), AuthError::token_expired());
assert_eq!(AuthError::session_expired(), AuthError::session_expired());
assert_eq!(AuthError::invalid_token("x"), AuthError::invalid_token("x"));
assert_ne!(AuthError::invalid_token("x"), AuthError::invalid_token("y"));
assert_ne!(AuthError::invalid_credentials(), AuthError::token_expired());
}
#[test]
fn error_query_methods_are_exclusive() {
let cases: Vec<(AuthError, &str)> = vec![
(AuthError::invalid_credentials(), "invalid_credentials"),
(AuthError::token_expired(), "token_expired"),
(AuthError::invalid_token("t"), "invalid_token"),
(
AuthError::invalid_configuration("c"),
"invalid_configuration",
),
(AuthError::crypto("c"), "crypto"),
(AuthError::session_expired(), "session_expired"),
(AuthError::provider("p"), "provider"),
];
for (err, expected_name) in &cases {
let hits: Vec<&str> = [
err.is_invalid_credentials()
.then_some("invalid_credentials"),
err.is_token_expired().then_some("token_expired"),
err.is_invalid_token().then_some("invalid_token"),
err.is_invalid_configuration()
.then_some("invalid_configuration"),
err.is_crypto().then_some("crypto"),
err.is_session_expired().then_some("session_expired"),
err.is_provider().then_some("provider"),
]
.into_iter()
.flatten()
.collect();
assert_eq!(
hits,
vec![*expected_name],
"expected exactly one query match for {expected_name}, got {hits:?}",
);
}
}
}