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));
    }
}