jwt.rs
599 lignes · 19219 octets
//! JWT token generation and validation //! //! This module handles: //! - JWT token generation with user claims (user_id, username, roles) //! - JWT token validation and claim extraction //! - Token expiration management use chrono::{DateTime, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::config::auth::AuthConfig; use crate::error::AppError; /// Origin of a JWT subject (RFC 0007). /// /// Lets middlewares distinguish a local user (`users.id` in `sub`) from /// a federated remote identity (`remote_identities.id` in `sub`) without /// touching the database. Persisted on the wire as the snake_case key /// `kind`. Tokens minted before RFC 0007 deserialise as `Local`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IdentityKind { /// Subject = `users.id` (local account). #[default] Local, /// Subject = `remote_identities.id` (federated user authenticated via /// the AP-native flow of RFC 0006). Remote, } /// JWT Claims structure /// /// Contains all information embedded in the JWT token. /// These claims are cryptographically signed and can be verified. /// /// # Standard Claims /// - `sub` (subject): User ID /// - `exp` (expiration): Token expiration timestamp /// - `iat` (issued at): Token creation timestamp /// - `iss` (issuer): Token issuer (e.g., "rustwarden") /// /// # Custom Claims /// - `username`: User's username /// - `roles`: User's roles for authorization /// /// # Security Notes /// /// - Claims are NOT encrypted, only signed /// - Do not include sensitive information (passwords, secrets) /// - Signature prevents tampering but not reading /// /// # Examples /// /// ``` /// use rustwarden_core::auth::jwt::Claims; /// use uuid::Uuid; /// use chrono::Utc; /// /// let claims = Claims { /// sub: Uuid::new_v4(), /// jti: Uuid::new_v4().to_string(), /// aud: "rustwarden".to_string(), /// username: "john-doe".to_string(), /// roles: vec!["user".to_string()], /// permissions: vec!["users.list".to_string()], /// exp: Utc::now().timestamp() + 900, /// iat: Utc::now().timestamp(), /// iss: "rustwarden".to_string(), /// identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local, /// remote_id: None, /// }; /// ``` #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { /// Subject (user ID) pub sub: Uuid, /// SEC-L2: JWT ID — unique identifier for token revocation and replay detection #[serde(default)] pub jti: String, /// SEC-L2: Audience — restricts which service should accept this token #[serde(default)] pub aud: String, /// Username pub username: String, /// User roles (e.g., ["admin"], ["user"]) pub roles: Vec<String>, /// User permissions (e.g., ["users.list", "roles.edit"]) #[serde(default)] pub permissions: Vec<String>, /// Expiration time (Unix timestamp) pub exp: i64, /// Issued at time (Unix timestamp) pub iat: i64, /// Issuer (e.g., "rustwarden") pub iss: String, /// RFC 0007: identity kind discriminating local users from remote /// (federated) ones. Defaults to [`IdentityKind::Local`] for legacy /// tokens that pre-date the field. #[serde(default, rename = "kind")] pub identity_kind: IdentityKind, /// RFC 0007: foreign key to `remote_identities.id` when /// `identity_kind = Remote`. Always `None` for local tokens. #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_id: Option<Uuid>, } impl Claims { /// Returns true when this token was issued for a federated remote /// identity (RFC 0006 flow). pub fn is_remote(&self) -> bool { matches!(self.identity_kind, IdentityKind::Remote) } /// Returns true when this token was issued for a local user. pub fn is_local(&self) -> bool { matches!(self.identity_kind, IdentityKind::Local) } /// Returns the `remote_identities.id` carried by this token, but /// only when [`Self::is_remote`] is true. A spurious `remote_id` /// alongside `kind = Local` is treated as `None` to avoid the /// confused-deputy hazard. pub fn remote_identity_id(&self) -> Option<Uuid> { if self.is_remote() { self.remote_id } else { None } } } /// JWT service for token operations pub struct JwtService; impl JwtService { /// Generate a JWT token for a user /// /// Creates a signed JWT token with user claims that expires after the /// configured duration (default 15 minutes). /// /// # Arguments /// /// * `config` - Authentication configuration (contains JWT secret and expiration) /// * `user_id` - User's unique identifier /// * `username` - User's username /// * `roles` - User's roles (e.g., ["admin"], ["user"]) /// /// # Returns /// /// A signed JWT token string /// /// # Errors /// /// Returns `AppError::Config` if token generation fails /// /// # Security /// /// - Token is signed with HS256 algorithm /// - Secret key must be at least 256 bits (enforced by AuthConfig) /// - Token includes expiration time (exp claim) /// - Token includes issued-at time (iat claim) for audit /// /// # Examples /// /// ```no_run /// use rustwarden_core::auth::jwt::JwtService; /// use rustwarden_core::config::auth::AuthConfig; /// use uuid::Uuid; /// /// let config = AuthConfig::from_env().unwrap(); /// let user_id = Uuid::new_v4(); /// let roles = vec!["user".to_string()]; /// let permissions = vec!["users.list".to_string()]; /// /// let token = JwtService::generate_token( /// &config, /// user_id, /// "john-doe", /// roles, /// permissions, /// ).unwrap(); /// /// println!("Generated token: {}", token); /// ``` pub fn generate_token( config: &AuthConfig, user_id: Uuid, username: &str, roles: Vec<String>, permissions: Vec<String>, ) -> Result<String, AppError> { let now = Utc::now(); let expiration = now + config.jwt_expiration_duration(); let claims = Claims { sub: user_id, jti: Uuid::new_v4().to_string(), aud: config.jwt_issuer.clone(), username: username.to_string(), roles, permissions, exp: expiration.timestamp(), iat: now.timestamp(), iss: config.jwt_issuer.clone(), identity_kind: IdentityKind::Local, remote_id: None, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(config.jwt_secret.as_bytes()), ) .map_err(|e| { tracing::error!("Failed to generate JWT token: {}", e); AppError::Config(format!("JWT generation failed: {}", e)) })?; tracing::debug!( "Generated JWT token for user {} (expires in {} minutes)", username, config.jwt_expiration_minutes ); Ok(token) } /// Generate a JWT token explicitly typed as a remote (federated) /// identity (RFC 0007). /// /// `remote_identity_id` becomes both `sub` and `remote_id` in the /// payload, and `identity_kind` is set to [`IdentityKind::Remote`]. /// `handle` is the canonical `@user@domain` string used as /// `username` for log/audit consistency. Remote tokens never carry /// roles or permissions — federated subjects authenticate against /// team-scoped grants only (RFC 0009). /// /// # Errors /// /// Returns `AppError::Config` if the underlying signing fails. pub fn generate_remote_token( config: &AuthConfig, remote_identity_id: Uuid, handle: &str, ) -> Result<String, AppError> { let now = Utc::now(); let expiration = now + config.jwt_expiration_duration(); let claims = Claims { sub: remote_identity_id, jti: Uuid::new_v4().to_string(), aud: config.jwt_issuer.clone(), username: handle.to_string(), roles: Vec::new(), permissions: Vec::new(), exp: expiration.timestamp(), iat: now.timestamp(), iss: config.jwt_issuer.clone(), identity_kind: IdentityKind::Remote, remote_id: Some(remote_identity_id), }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(config.jwt_secret.as_bytes()), ) .map_err(|e| { tracing::error!("Failed to generate remote JWT token: {}", e); AppError::Config(format!("JWT generation failed: {}", e)) })?; Ok(token) } /// Validate a JWT token and extract claims /// /// Verifies the token signature, checks expiration, and extracts the claims. /// /// # Arguments /// /// * `config` - Authentication configuration (contains JWT secret and issuer) /// * `token` - JWT token string to validate /// /// # Returns /// /// The validated claims if token is valid /// /// # Errors /// /// - `AppError::InvalidToken` - Token signature is invalid or malformed /// - `AppError::TokenExpired` - Token has expired /// /// # Security /// /// - Validates signature using HS256 /// - Checks expiration time (exp claim) /// - Validates issuer (iss claim) /// - Constant-time signature verification (via jsonwebtoken crate) /// /// # Examples /// /// ```no_run /// use rustwarden_core::auth::jwt::JwtService; /// use rustwarden_core::config::auth::AuthConfig; /// /// let config = AuthConfig::from_env().unwrap(); /// let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."; /// /// match JwtService::validate_token(&config, token) { /// Ok(claims) => println!("User: {}", claims.username), /// Err(e) => println!("Invalid token: {}", e), /// } /// ``` pub fn validate_token(config: &AuthConfig, token: &str) -> Result<Claims, AppError> { let mut validation = Validation::default(); validation.set_issuer(&[&config.jwt_issuer]); // SEC-L2: Validate audience matches issuer validation.set_audience(&[&config.jwt_issuer]); let token_data = decode::<Claims>( token, &DecodingKey::from_secret(config.jwt_secret.as_bytes()), &validation, ) .map_err(|e| match e.kind() { jsonwebtoken::errors::ErrorKind::ExpiredSignature => { tracing::debug!("JWT token expired"); AppError::TokenExpired } _ => { tracing::warn!("JWT validation failed: {}", e); AppError::InvalidToken } })?; tracing::debug!( "JWT token validated for user: {}", token_data.claims.username ); Ok(token_data.claims) } /// Extract token from Authorization header /// /// Parses the `"Bearer <token>"` format from HTTP Authorization header. /// /// # Arguments /// /// * `auth_header` - Authorization header value (e.g., "Bearer eyJ...") /// /// # Returns /// /// The extracted token string without "Bearer " prefix /// /// # Errors /// /// Returns `AppError::InvalidToken` if header format is invalid /// /// # Examples /// /// ``` /// use rustwarden_core::auth::jwt::JwtService; /// /// let header = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."; /// let token = JwtService::extract_token_from_header(header).unwrap(); /// assert_eq!(token, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."); /// ``` pub fn extract_token_from_header(auth_header: &str) -> Result<&str, AppError> { if !auth_header.starts_with("Bearer ") { return Err(AppError::InvalidToken); } let token = &auth_header[7..]; // Skip "Bearer " if token.is_empty() { return Err(AppError::InvalidToken); } Ok(token) } /// Get expiration DateTime from claims /// /// Converts the exp claim (Unix timestamp) to a `DateTime<Utc>`. /// /// # Arguments /// /// * `claims` - JWT claims containing exp timestamp /// /// # Returns /// #[allow(dead_code)] /// DateTime representing when the token expires pub fn get_expiration(claims: &Claims) -> DateTime<Utc> { DateTime::from_timestamp(claims.exp, 0).unwrap_or_else(Utc::now) } /// Check if token is expired /// /// Compares the exp claim against current time. /// /// # Arguments /// /// * `claims` - JWT claims containing exp timestamp /// /// # Returns /// /// `true` if token is expired, `false` otherwise #[allow(dead_code)] pub fn is_expired(claims: &Claims) -> bool { let exp_time = Self::get_expiration(claims); exp_time < Utc::now() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_token_from_header() { // Valid header let header = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; let token = JwtService::extract_token_from_header(header).unwrap(); assert_eq!(token, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"); // Invalid header (no Bearer prefix) let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; assert!(JwtService::extract_token_from_header(header).is_err()); // Invalid header (empty token) let header = "Bearer "; assert!(JwtService::extract_token_from_header(header).is_err()); } #[test] fn test_claims_serialization() { let claims = Claims { sub: Uuid::new_v4(), jti: Uuid::new_v4().to_string(), aud: "rustwarden".to_string(), username: "test_user".to_string(), roles: vec!["user".to_string()], permissions: vec!["users.list".to_string()], exp: Utc::now().timestamp() + 900, iat: Utc::now().timestamp(), iss: "rustwarden".to_string(), identity_kind: IdentityKind::Local, remote_id: None, }; let json = serde_json::to_string(&claims); assert!(json.is_ok()); } #[test] fn test_is_expired() { let claims = Claims { sub: Uuid::new_v4(), jti: Uuid::new_v4().to_string(), aud: "rustwarden".to_string(), username: "test".to_string(), roles: vec![], permissions: vec![], exp: (Utc::now() + chrono::Duration::hours(1)).timestamp(), iat: Utc::now().timestamp(), iss: "rustwarden".to_string(), identity_kind: IdentityKind::Local, remote_id: None, }; assert!(!JwtService::is_expired(&claims)); let expired_claims = Claims { sub: Uuid::new_v4(), jti: Uuid::new_v4().to_string(), aud: "rustwarden".to_string(), username: "test".to_string(), roles: vec![], permissions: vec![], exp: (Utc::now() - chrono::Duration::hours(1)).timestamp(), iat: Utc::now().timestamp(), iss: "rustwarden".to_string(), identity_kind: IdentityKind::Local, remote_id: None, }; assert!(JwtService::is_expired(&expired_claims)); } // Note: Integration tests with real token generation/validation // require AuthConfig and are in tests/integration/ // ─── RFC 0007 — IdentityKind & remote_id ───────────────────────── fn make_local_claims() -> Claims { Claims { sub: Uuid::new_v4(), jti: Uuid::new_v4().to_string(), aud: "rustwarden".to_string(), username: "alice".to_string(), roles: vec![], permissions: vec![], exp: Utc::now().timestamp() + 900, iat: Utc::now().timestamp(), iss: "rustwarden".to_string(), identity_kind: IdentityKind::Local, remote_id: None, } } #[test] fn rfc0007_default_identity_kind_is_local() { assert_eq!(IdentityKind::default(), IdentityKind::Local); } #[test] fn rfc0007_claims_serialise_kind_as_snake_case() { let mut claims = make_local_claims(); claims.identity_kind = IdentityKind::Remote; claims.remote_id = Some(Uuid::new_v4()); let json = serde_json::to_value(&claims).unwrap(); assert_eq!(json["kind"], serde_json::json!("remote")); assert!(json["remote_id"].is_string()); } #[test] fn rfc0007_claims_deserialise_legacy_token_as_local() { let legacy = serde_json::json!({ "sub": Uuid::new_v4(), "jti": Uuid::new_v4().to_string(), "aud": "rustwarden", "username": "bob", "roles": [], "permissions": [], "exp": 4_102_444_800_i64, "iat": 1_704_067_200_i64, "iss": "rustwarden" }); let claims: Claims = serde_json::from_value(legacy).unwrap(); assert_eq!(claims.identity_kind, IdentityKind::Local); assert!(claims.remote_id.is_none()); } #[test] fn rfc0007_claims_remote_id_skips_when_none() { let claims = make_local_claims(); let json = serde_json::to_value(&claims).unwrap(); assert!( json.get("remote_id").is_none(), "remote_id must be omitted when None, payload was: {json}" ); } #[test] fn rfc0007_is_remote_returns_true_for_remote_token() { let mut claims = make_local_claims(); claims.identity_kind = IdentityKind::Remote; claims.remote_id = Some(Uuid::new_v4()); assert!(claims.is_remote()); assert!(!claims.is_local()); } #[test] fn rfc0007_is_local_returns_true_when_default() { let claims = make_local_claims(); assert!(claims.is_local()); assert!(!claims.is_remote()); } #[test] fn rfc0007_remote_identity_id_returns_none_for_local() { let mut claims = make_local_claims(); // A leaked `remote_id` on a local token must NOT be exposed: // confused-deputy guard. claims.remote_id = Some(Uuid::new_v4()); assert!(claims.remote_identity_id().is_none()); } #[test] fn rfc0007_remote_identity_id_returns_some_for_remote() { let id = Uuid::new_v4(); let mut claims = make_local_claims(); claims.identity_kind = IdentityKind::Remote; claims.remote_id = Some(id); assert_eq!(claims.remote_identity_id(), Some(id)); } }
GitRust