personal_token_service.rs 582 lignes · 19297 octets
//! Personal access token service.
//!
//! Generic over a user-defined `TokenScope` so each consumer app keeps a
//! type-safe vocabulary (e.g. gitrust → `RepoScope`, PasterMan → `WallScope`)
//! while sharing the hash/verify/revoke/cleanup plumbing.
//!
//! # Security
//!
//! - Token plaintext is returned once from `PersonalTokenService::create`
//!   and never persisted. Only the SHA-256 digest is stored.
//! - `PersonalTokenService::verify` uses a constant-time comparison on the
//!   hash (via `subtle`) to reject timing oracles.
//! - Expired and revoked tokens refuse authentication without leaking the
//!   reason (uniform `AppError::InvalidToken`).
//!
//! # Example
//!
//! ```rust,ignore
//! use rustwarden_core::services::personal_token_service::{
//!     PersonalTokenService, TokenScope,
//! };
//!
//! #[derive(Clone, Debug)]
//! enum RepoScope {
//!     Read,
//!     Write,
//! }
//!
//! impl TokenScope for RepoScope {
//!     fn as_str(&self) -> &str {
//!         match self {
//!             RepoScope::Read => "read:repo",
//!             RepoScope::Write => "write:repo",
//!         }
//!     }
//!     fn parse(s: &str) -> Option<Self> {
//!         match s {
//!             "read:repo" => Some(Self::Read),
//!             "write:repo" => Some(Self::Write),
//!             _ => None,
//!         }
//!     }
//! }
//! ```

use crate::error::AppError;
use crate::models::personal_access_token;
use chrono::{DateTime, Utc};
use rand::RngCore;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
};
use sha2::{Digest, Sha256};
use std::marker::PhantomData;
use subtle::ConstantTimeEq;
use uuid::Uuid;

/// Scope vocabulary implemented by each consumer app.
///
/// Scopes are stored as their `as_str()` representation in the DB; a token
/// created with an unknown-to-the-app scope string is silently dropped from
/// the parsed list returned by [`PersonalTokenService::verify`], so rolling
/// back a scope definition does not invalidate prior tokens outright — they
/// just lose the removed capability.
pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
    /// Canonical string representation (e.g. `"read:repo"`).
    fn as_str(&self) -> &str;
    /// Parse back from the canonical string. Returns `None` for unknown values.
    fn parse(s: &str) -> Option<Self>;
}

/// Personal access token service parameterised by a scope vocabulary.
///
/// All methods are associated fns (no state) — the `PhantomData<S>` only
/// binds the scope type for the trait impl. Construct as
/// `PersonalTokenService::<MyScope>::create(...)` or keep a type alias.
pub struct PersonalTokenService<S: TokenScope> {
    _phantom: PhantomData<S>,
}

impl<S: TokenScope> PersonalTokenService<S> {
    /// Create a new PAT and return the (model, plaintext) pair.
    ///
    /// The plaintext is shown once to the user and MUST NOT be persisted
    /// elsewhere by the caller — only its SHA-256 digest lives in the DB.
    ///
    /// # Errors
    ///
    /// - `AppError::Validation` if `name` is empty.
    /// - `AppError::Database` on insertion failure (FK violation, unique
    ///   constraint on `(user_id, name)` or `token_hash`).
    pub async fn create(
        db: &DatabaseConnection,
        user_id: Uuid,
        name: &str,
        scopes: &[S],
        expires_at: Option<DateTime<Utc>>,
    ) -> Result<(personal_access_token::Model, String), AppError> {
        if name.trim().is_empty() {
            return Err(AppError::Validation(
                "Token name cannot be empty".to_string(),
            ));
        }

        let plain = generate_token_plain();
        let token_hash = hash_token(&plain);
        let scope_strings: Vec<String> = scopes.iter().map(|s| s.as_str().to_string()).collect();

        let active = personal_access_token::ActiveModel {
            id: Set(Uuid::new_v4()),
            user_id: Set(user_id),
            name: Set(name.to_string()),
            token_hash: Set(token_hash),
            scopes: Set(scope_strings),
            expires_at: Set(expires_at),
            last_used_at: NotSet,
            revoked_at: NotSet,
            created_at: Set(Utc::now()),
        };

        let model = active.insert(db).await?;
        Ok((model, plain))
    }

    /// Verify a plaintext token and return the record plus parsed scopes.
    ///
    /// Touches `last_used_at` on success (best-effort; update failure only
    /// logs a warning). Rejects tokens that are revoked, expired, or whose
    /// hash does not match anything in the DB — always with
    /// [`AppError::InvalidToken`] to avoid leaking which condition failed.
    pub async fn verify(
        db: &DatabaseConnection,
        token_clear: &str,
    ) -> Result<(personal_access_token::Model, Vec<S>), AppError> {
        if token_clear.is_empty() {
            return Err(AppError::InvalidToken);
        }

        let candidate_hash = hash_token(token_clear);
        let row = personal_access_token::Entity::find()
            .filter(personal_access_token::Column::TokenHash.eq(&candidate_hash))
            .one(db)
            .await?
            .ok_or(AppError::InvalidToken)?;

        // Constant-time re-check — the SQL lookup already matched on the hash,
        // but this guards against DB-side comparison being variable-time.
        if !bool::from(row.token_hash.as_bytes().ct_eq(candidate_hash.as_bytes())) {
            return Err(AppError::InvalidToken);
        }

        if !row.is_active() {
            return Err(AppError::InvalidToken);
        }

        // Best-effort last_used_at update (don't block auth on slow write).
        let id = row.id;
        let mut touched: personal_access_token::ActiveModel = row.clone().into();
        touched.last_used_at = Set(Some(Utc::now()));
        if let Err(e) = touched.update(db).await {
            tracing::warn!(token_id = %id, "PAT last_used_at update failed: {}", e);
        }

        let scopes: Vec<S> = row.scopes.iter().filter_map(|s| S::parse(s)).collect();
        Ok((row, scopes))
    }

    /// List all non-revoked PATs for a user (most recent first).
    pub async fn list_for_user(
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<Vec<personal_access_token::Model>, AppError> {
        use sea_orm::QueryOrder;
        let rows = personal_access_token::Entity::find()
            .filter(personal_access_token::Column::UserId.eq(user_id))
            .filter(personal_access_token::Column::RevokedAt.is_null())
            .order_by_desc(personal_access_token::Column::CreatedAt)
            .all(db)
            .await?;
        Ok(rows)
    }

    /// Revoke a PAT by id. Idempotent: revoking an already-revoked token is
    /// a no-op. Refuses if the token does not belong to `user_id` (ownership
    /// enforcement — otherwise an attacker knowing the UUID could revoke
    /// another user's token).
    pub async fn revoke(
        db: &DatabaseConnection,
        token_id: Uuid,
        user_id: Uuid,
    ) -> Result<(), AppError> {
        let row = personal_access_token::Entity::find_by_id(token_id)
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("PAT {token_id}")))?;

        if row.user_id != user_id {
            return Err(AppError::Forbidden);
        }

        if row.revoked_at.is_some() {
            return Ok(()); // idempotent
        }

        let mut active: personal_access_token::ActiveModel = row.into();
        active.revoked_at = Set(Some(Utc::now()));
        active.update(db).await?;
        Ok(())
    }

    /// Background cleanup: hard-delete tokens that are both expired AND
    /// revoked (either condition alone is kept for audit window). Returns
    /// the number of rows removed.
    pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> {
        let now = Utc::now();
        let result = personal_access_token::Entity::delete_many()
            .filter(
                sea_orm::Condition::all()
                    .add(personal_access_token::Column::ExpiresAt.is_not_null())
                    .add(personal_access_token::Column::ExpiresAt.lt(now))
                    .add(personal_access_token::Column::RevokedAt.is_not_null()),
            )
            .exec(db)
            .await?;
        Ok(result.rows_affected)
    }
}

/// Generate a fresh 32-byte token encoded as 64 hex chars (URL-safe).
fn generate_token_plain() -> String {
    let mut bytes = [0u8; 32];
    rand::rng().fill_bytes(&mut bytes);
    hex::encode(bytes)
}

/// SHA-256 hex of the plaintext token — deterministic indexing key.
fn hash_token(plain: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(plain.as_bytes());
    hex::encode(hasher.finalize())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::services::password_service::PasswordService;
    use crate::services::user_service::UserService;
    use chrono::Duration;

    /// Minimal scope type exercising the generic bounds.
    #[derive(Clone, Debug, PartialEq, Eq)]
    enum TestScope {
        Read,
        Write,
    }

    impl TokenScope for TestScope {
        fn as_str(&self) -> &str {
            match self {
                Self::Read => "test:read",
                Self::Write => "test:write",
            }
        }
        fn parse(s: &str) -> Option<Self> {
            match s {
                "test:read" => Some(Self::Read),
                "test:write" => Some(Self::Write),
                _ => None,
            }
        }
    }

    /// Per-test connection. A shared `OnceCell<DatabaseConnection>` cannot be
    /// reused across `#[tokio::test]` calls because each creates its own
    /// runtime; the pool captured by the first test outlives it and later
    /// tests see "Tokio 1.x context being shutdown". Re-connecting per test
    /// costs ~10ms on localhost Postgres — worth the isolation.
    async fn test_db() -> DatabaseConnection {
        dotenvy::dotenv().ok();
        let url = std::env::var("DATABASE_URL")
            .expect("DATABASE_URL required for personal_token_service tests");
        sea_orm::Database::connect(&url).await.unwrap()
    }

    /// Isolates each test with a unique user. Avoids polluting the dev DB.
    async fn fresh_user(db: &DatabaseConnection) -> Uuid {
        let unique = Uuid::new_v4().simple().to_string();
        let hash = PasswordService::hash_password("NotReallyUsed123!")
            .await
            .unwrap();
        let user = UserService::create_user(
            db,
            format!("pat-test-{}", &unique[..12]),
            format!("pat_test_{}@example.com", &unique[..12]),
            hash,
            false,
        )
        .await
        .unwrap();
        user.id
    }

    #[tokio::test]
    async fn create_returns_plaintext_and_persists_hash_only() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;

        let (record, plain) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "ci-bot",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap();

        assert_eq!(plain.len(), 64, "token should be 32 bytes hex");
        assert_ne!(
            record.token_hash, plain,
            "DB must store hash, not plaintext"
        );
        assert_eq!(record.token_hash.len(), 64, "SHA-256 hex is 64 chars");
        assert_eq!(record.scopes, vec!["test:read".to_string()]);
        assert!(record.expires_at.is_none());
        assert!(record.last_used_at.is_none());
        assert!(record.revoked_at.is_none());
    }

    #[tokio::test]
    async fn create_rejects_empty_name() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;
        let err = PersonalTokenService::<TestScope>::create(&db, user_id, "   ", &[], None)
            .await
            .unwrap_err();
        matches!(err, AppError::Validation(_));
    }

    #[tokio::test]
    async fn verify_accepts_valid_token_and_updates_last_used_at() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;
        let (record, plain) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "ci-verify",
            &[TestScope::Read, TestScope::Write],
            None,
        )
        .await
        .unwrap();

        let (verified, scopes) = PersonalTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap();

        assert_eq!(verified.id, record.id);
        assert_eq!(
            scopes,
            vec![TestScope::Read, TestScope::Write],
            "scopes must round-trip through parse()"
        );

        // Reload to assert last_used_at was actually persisted.
        let fresh = personal_access_token::Entity::find_by_id(record.id)
            .one(&db)
            .await
            .unwrap()
            .unwrap();
        assert!(
            fresh.last_used_at.is_some(),
            "last_used_at must be set after verify()"
        );
    }

    #[tokio::test]
    async fn verify_rejects_unknown_token() {
        let db = test_db().await;
        let err =
            PersonalTokenService::<TestScope>::verify(&db, "deadbeefdeadbeefdeadbeefdeadbeef")
                .await
                .unwrap_err();
        assert!(matches!(err, AppError::InvalidToken));
    }

    #[tokio::test]
    async fn verify_rejects_revoked_token() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;
        let (record, plain) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "ci-revoke",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap();
        PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id)
            .await
            .unwrap();

        let err = PersonalTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap_err();
        assert!(matches!(err, AppError::InvalidToken));
    }

    #[tokio::test]
    async fn verify_rejects_expired_token() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;
        let (_record, plain) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "ci-expired",
            &[TestScope::Read],
            Some(Utc::now() - Duration::seconds(1)),
        )
        .await
        .unwrap();

        let err = PersonalTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap_err();
        assert!(matches!(err, AppError::InvalidToken));
    }

    #[tokio::test]
    async fn list_for_user_returns_only_active_tokens() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;

        let (t1, _) =
            PersonalTokenService::<TestScope>::create(&db, user_id, "t1", &[TestScope::Read], None)
                .await
                .unwrap();
        let (t2, _) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "t2",
            &[TestScope::Write],
            None,
        )
        .await
        .unwrap();

        PersonalTokenService::<TestScope>::revoke(&db, t2.id, user_id)
            .await
            .unwrap();

        let listed = PersonalTokenService::<TestScope>::list_for_user(&db, user_id)
            .await
            .unwrap();
        assert_eq!(listed.len(), 1, "revoked token must not appear in list");
        assert_eq!(listed[0].id, t1.id);
    }

    #[tokio::test]
    async fn revoke_refuses_other_users_tokens() {
        let db = test_db().await;
        let owner = fresh_user(&db).await;
        let attacker = fresh_user(&db).await;
        let (record, _) = PersonalTokenService::<TestScope>::create(
            &db,
            owner,
            "owned",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap();

        let err = PersonalTokenService::<TestScope>::revoke(&db, record.id, attacker)
            .await
            .unwrap_err();
        assert!(matches!(err, AppError::Forbidden));

        // Token must still be usable — cross-user revoke must not have fired.
        let reloaded = personal_access_token::Entity::find_by_id(record.id)
            .one(&db)
            .await
            .unwrap()
            .unwrap();
        assert!(reloaded.revoked_at.is_none());
    }

    #[tokio::test]
    async fn revoke_is_idempotent() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;
        let (record, _) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "idempotent",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap();

        PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id)
            .await
            .unwrap();
        PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id)
            .await
            .expect("second revoke must be a no-op");
    }

    #[tokio::test]
    async fn cleanup_expired_removes_only_expired_and_revoked_rows() {
        let db = test_db().await;
        let user_id = fresh_user(&db).await;

        // 1. Expired + revoked → should be deleted.
        let (expired_revoked, _) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "expired-revoked",
            &[TestScope::Read],
            Some(Utc::now() - Duration::hours(1)),
        )
        .await
        .unwrap();
        PersonalTokenService::<TestScope>::revoke(&db, expired_revoked.id, user_id)
            .await
            .unwrap();

        // 2. Expired only (no revoke) → keep for audit.
        let (expired_only, _) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "expired-only",
            &[TestScope::Read],
            Some(Utc::now() - Duration::hours(1)),
        )
        .await
        .unwrap();

        // 3. Active → must stay.
        let (active, _) = PersonalTokenService::<TestScope>::create(
            &db,
            user_id,
            "active",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap();

        let removed = PersonalTokenService::<TestScope>::cleanup_expired(&db)
            .await
            .unwrap();
        assert!(removed >= 1);

        assert!(
            personal_access_token::Entity::find_by_id(expired_revoked.id)
                .one(&db)
                .await
                .unwrap()
                .is_none(),
            "expired+revoked must be deleted"
        );
        assert!(
            personal_access_token::Entity::find_by_id(expired_only.id)
                .one(&db)
                .await
                .unwrap()
                .is_some(),
            "expired-only must be kept for audit"
        );
        assert!(
            personal_access_token::Entity::find_by_id(active.id)
                .one(&db)
                .await
                .unwrap()
                .is_some(),
            "active token must not be touched"
        );
    }
}