allframe_core/auth/
mod.rs

1//! Authentication primitives for AllFrame.
2//!
3//! This module provides a layered authentication system:
4//!
5//! - **`auth`** (this module): Core traits with zero dependencies
6//! - **`auth-jwt`**: JWT validation using `jsonwebtoken`
7//! - **`auth-axum`**: Axum extractors and middleware
8//! - **`auth-tonic`**: gRPC interceptors
9//!
10//! # Core Concepts
11//!
12//! The authentication system is built around a few key traits:
13//!
14//! - [`Authenticator`]: Validates tokens and returns claims
15//! - [`Claims`]: Marker trait for claim types
16//! - [`AuthContext`]: Holds authenticated user information
17//!
18//! # Example: Using Core Traits
19//!
20//! ```rust
21//! use allframe_core::auth::{Authenticator, AuthError, AuthContext};
22//!
23//! // Define your claims type
24//! #[derive(Clone, Debug)]
25//! struct MyClaims {
26//!     sub: String,
27//!     email: Option<String>,
28//! }
29//!
30//! // Implement your authenticator
31//! struct MyAuthenticator;
32//!
33//! #[async_trait::async_trait]
34//! impl Authenticator for MyAuthenticator {
35//!     type Claims = MyClaims;
36//!
37//!     async fn authenticate(&self, token: &str) -> Result<Self::Claims, AuthError> {
38//!         // Your validation logic here
39//!         Ok(MyClaims {
40//!             sub: "user123".to_string(),
41//!             email: Some("user@example.com".to_string()),
42//!         })
43//!     }
44//! }
45//! ```
46//!
47//! # Feature Flags
48//!
49//! | Feature | Description |
50//! |---------|-------------|
51//! | `auth` | Core traits (this module) |
52//! | `auth-jwt` | JWT validation with HS256/RS256 support |
53//! | `auth-axum` | Axum extractors and middleware |
54//! | `auth-tonic` | gRPC interceptors |
55
56use std::fmt;
57
58#[cfg(feature = "auth-jwt")]
59pub mod jwt;
60
61#[cfg(feature = "auth-axum")]
62pub mod axum;
63
64#[cfg(feature = "auth-tonic")]
65pub mod tonic;
66
67// Re-exports
68#[cfg(feature = "auth-jwt")]
69pub use jwt::{JwtAlgorithm, JwtConfig, JwtValidator};
70
71#[cfg(feature = "auth-axum")]
72pub use self::axum::{AuthLayer, AuthenticatedUser};
73
74#[cfg(feature = "auth-tonic")]
75pub use self::tonic::AuthInterceptor;
76
77/// Error type for authentication failures.
78#[derive(Debug, Clone)]
79pub enum AuthError {
80    /// No token was provided.
81    MissingToken,
82    /// Token format is invalid.
83    InvalidToken(String),
84    /// Token has expired.
85    TokenExpired,
86    /// Token signature is invalid.
87    InvalidSignature,
88    /// Token issuer doesn't match.
89    InvalidIssuer,
90    /// Token audience doesn't match.
91    InvalidAudience,
92    /// Custom validation error.
93    ValidationFailed(String),
94    /// Internal error during authentication.
95    Internal(String),
96}
97
98impl fmt::Display for AuthError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            AuthError::MissingToken => write!(f, "missing authentication token"),
102            AuthError::InvalidToken(msg) => write!(f, "invalid token: {}", msg),
103            AuthError::TokenExpired => write!(f, "token has expired"),
104            AuthError::InvalidSignature => write!(f, "invalid token signature"),
105            AuthError::InvalidIssuer => write!(f, "invalid token issuer"),
106            AuthError::InvalidAudience => write!(f, "invalid token audience"),
107            AuthError::ValidationFailed(msg) => write!(f, "validation failed: {}", msg),
108            AuthError::Internal(msg) => write!(f, "internal auth error: {}", msg),
109        }
110    }
111}
112
113impl std::error::Error for AuthError {}
114
115impl AuthError {
116    /// Check if this is a "missing token" error (vs invalid token).
117    pub fn is_missing(&self) -> bool {
118        matches!(self, AuthError::MissingToken)
119    }
120
121    /// Check if this is an expiration error.
122    pub fn is_expired(&self) -> bool {
123        matches!(self, AuthError::TokenExpired)
124    }
125
126    /// Get an appropriate HTTP status code for this error.
127    pub fn status_code(&self) -> u16 {
128        match self {
129            AuthError::MissingToken => 401,
130            AuthError::InvalidToken(_) => 401,
131            AuthError::TokenExpired => 401,
132            AuthError::InvalidSignature => 401,
133            AuthError::InvalidIssuer => 401,
134            AuthError::InvalidAudience => 401,
135            AuthError::ValidationFailed(_) => 403,
136            AuthError::Internal(_) => 500,
137        }
138    }
139}
140
141/// Trait for types that can validate authentication tokens.
142///
143/// Implement this trait to create custom authenticators for different
144/// token types (JWT, API keys, session tokens, etc.).
145///
146/// # Example
147///
148/// ```rust
149/// use allframe_core::auth::{Authenticator, AuthError};
150///
151/// struct ApiKeyAuthenticator {
152///     valid_keys: Vec<String>,
153/// }
154///
155/// #[async_trait::async_trait]
156/// impl Authenticator for ApiKeyAuthenticator {
157///     type Claims = String; // Just the key itself
158///
159///     async fn authenticate(&self, token: &str) -> Result<Self::Claims, AuthError> {
160///         if self.valid_keys.contains(&token.to_string()) {
161///             Ok(token.to_string())
162///         } else {
163///             Err(AuthError::InvalidToken("unknown API key".into()))
164///         }
165///     }
166/// }
167/// ```
168#[async_trait::async_trait]
169pub trait Authenticator: Send + Sync {
170    /// The claims type returned on successful authentication.
171    type Claims: Clone + Send + Sync + 'static;
172
173    /// Validate a token and extract claims.
174    ///
175    /// # Arguments
176    /// * `token` - The raw token string (without "Bearer " prefix)
177    ///
178    /// # Returns
179    /// * `Ok(Claims)` - Authentication successful
180    /// * `Err(AuthError)` - Authentication failed
181    async fn authenticate(&self, token: &str) -> Result<Self::Claims, AuthError>;
182}
183
184/// Context holding authenticated user information.
185///
186/// This is the result of successful authentication and contains
187/// the validated claims.
188#[derive(Clone, Debug)]
189pub struct AuthContext<C> {
190    /// The validated claims.
191    pub claims: C,
192    /// The original token (for forwarding to downstream services).
193    pub token: String,
194}
195
196impl<C: Clone> AuthContext<C> {
197    /// Create a new auth context.
198    pub fn new(claims: C, token: impl Into<String>) -> Self {
199        Self {
200            claims,
201            token: token.into(),
202        }
203    }
204
205    /// Get the claims.
206    pub fn claims(&self) -> &C {
207        &self.claims
208    }
209
210    /// Get the original token.
211    pub fn token(&self) -> &str {
212        &self.token
213    }
214
215    /// Extract a value from claims using a closure.
216    pub fn get<T>(&self, f: impl FnOnce(&C) -> T) -> T {
217        f(&self.claims)
218    }
219}
220
221/// Extract bearer token from an authorization header value.
222///
223/// # Example
224///
225/// ```rust
226/// use allframe_core::auth::extract_bearer_token;
227///
228/// assert_eq!(extract_bearer_token("Bearer abc123"), Some("abc123"));
229/// assert_eq!(extract_bearer_token("bearer ABC"), Some("ABC"));
230/// assert_eq!(extract_bearer_token("Basic xyz"), None);
231/// assert_eq!(extract_bearer_token("abc123"), None);
232/// ```
233pub fn extract_bearer_token(header_value: &str) -> Option<&str> {
234    let header = header_value.trim();
235    if header.len() > 7 && header[..7].eq_ignore_ascii_case("bearer ") {
236        Some(header[7..].trim())
237    } else {
238        None
239    }
240}
241
242/// Trait for claims that have a subject (user ID).
243pub trait HasSubject {
244    /// Get the subject (user ID) from the claims.
245    fn subject(&self) -> &str;
246}
247
248/// Trait for claims that have an expiration time.
249pub trait HasExpiration {
250    /// Get the expiration timestamp (Unix seconds).
251    fn expiration(&self) -> Option<i64>;
252
253    /// Check if the claims have expired.
254    fn is_expired(&self) -> bool {
255        if let Some(exp) = self.expiration() {
256            let now = std::time::SystemTime::now()
257                .duration_since(std::time::UNIX_EPOCH)
258                .unwrap()
259                .as_secs() as i64;
260            exp < now
261        } else {
262            false
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_extract_bearer_token() {
273        assert_eq!(extract_bearer_token("Bearer abc123"), Some("abc123"));
274        assert_eq!(extract_bearer_token("bearer ABC"), Some("ABC"));
275        assert_eq!(extract_bearer_token("BEARER token"), Some("token"));
276        assert_eq!(extract_bearer_token("Bearer  spaced"), Some("spaced"));
277        assert_eq!(extract_bearer_token("Basic xyz"), None);
278        assert_eq!(extract_bearer_token("abc123"), None);
279        assert_eq!(extract_bearer_token(""), None);
280        assert_eq!(extract_bearer_token("Bearer"), None);
281        // "Bearer " with no token after is treated as invalid
282        assert_eq!(extract_bearer_token("Bearer "), None);
283    }
284
285    #[test]
286    fn test_auth_error_display() {
287        assert_eq!(
288            AuthError::MissingToken.to_string(),
289            "missing authentication token"
290        );
291        assert_eq!(
292            AuthError::TokenExpired.to_string(),
293            "token has expired"
294        );
295        assert_eq!(
296            AuthError::InvalidToken("bad".into()).to_string(),
297            "invalid token: bad"
298        );
299    }
300
301    #[test]
302    fn test_auth_error_status_codes() {
303        assert_eq!(AuthError::MissingToken.status_code(), 401);
304        assert_eq!(AuthError::TokenExpired.status_code(), 401);
305        assert_eq!(AuthError::ValidationFailed("".into()).status_code(), 403);
306        assert_eq!(AuthError::Internal("".into()).status_code(), 500);
307    }
308
309    #[test]
310    fn test_auth_context() {
311        #[derive(Clone, Debug)]
312        struct TestClaims {
313            sub: String,
314            role: String,
315        }
316
317        let ctx = AuthContext::new(
318            TestClaims {
319                sub: "user123".into(),
320                role: "admin".into(),
321            },
322            "token123",
323        );
324
325        assert_eq!(ctx.claims().sub, "user123");
326        assert_eq!(ctx.token(), "token123");
327        assert_eq!(ctx.get(|c| c.role.clone()), "admin");
328    }
329
330    #[test]
331    fn test_auth_error_predicates() {
332        assert!(AuthError::MissingToken.is_missing());
333        assert!(!AuthError::TokenExpired.is_missing());
334        assert!(AuthError::TokenExpired.is_expired());
335        assert!(!AuthError::MissingToken.is_expired());
336    }
337
338    #[derive(Clone)]
339    struct MockClaims {
340        exp: Option<i64>,
341    }
342
343    impl HasExpiration for MockClaims {
344        fn expiration(&self) -> Option<i64> {
345            self.exp
346        }
347    }
348
349    #[test]
350    fn test_has_expiration() {
351        let past = MockClaims { exp: Some(0) };
352        assert!(past.is_expired());
353
354        let future = MockClaims {
355            exp: Some(i64::MAX),
356        };
357        assert!(!future.is_expired());
358
359        let no_exp = MockClaims { exp: None };
360        assert!(!no_exp.is_expired());
361    }
362}