remote_personal_access_token_service.rs 664 lignes · 23991 octets
//! Personal access tokens issued by the local instance to remote
//! ActivityPub identities (RFC 0030).
//!
//! Companion of
//! [`rustwarden_core::services::personal_token_service::PersonalTokenService`]
//! but scoped to `remote_identities` instead of `users`. Generic over a
//! `TokenScope` type so consumer apps reuse their existing scope
//! vocabularies (e.g. gitrust's `RepoScope`) for both local and remote
//! PATs.
//!
//! # Token format
//!
//! Plaintext tokens are prefixed `rwfed_pat_` followed by 64 hex
//! characters (32 bytes of entropy). The prefix discriminates remote
//! PATs from framework PATs (`rw_pat_…` historically — the core
//! implementation currently emits bare hex but the consumer must route
//! by prefix). Tokens emitted by this service always include the
//! prefix; the verifier strips it before hashing.
//!
//! # Security
//!
//! - SHA-256 of the plaintext is stored; the plaintext is returned once
//!   from `RemotePersonalAccessTokenService::create`.
//! - Constant-time hash comparison via `subtle` in
//!   `RemotePersonalAccessTokenService::verify`.
//! - A token whose owning `remote_identity` is `disabled_at IS NOT NULL`
//!   is rejected uniformly (returns `None`), defending against the
//!   "actor offline since" race after a `Delete{Actor}` signal.

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

/// Prefix prepended to every plaintext token emitted by this service.
///
/// Tokens whose plaintext does not start with this prefix are rejected
/// up-front by [`RemotePersonalAccessTokenService::verify`].
pub const TOKEN_PREFIX: &str = "rwfed_pat_";

/// Maximum length of the user-chosen `name` field.
const MAX_NAME_LEN: usize = 64;

/// Personal access tokens for remote ActivityPub identities,
/// parameterised by a scope vocabulary.
///
/// All methods are associated `async fn` — the `PhantomData<S>` only
/// binds the scope type for the trait impl. Construct as
/// `RemotePersonalAccessTokenService::<MyScope>::create(...)` or keep a
/// type alias on the consumer side.
///
/// # Example
///
/// ```rust,ignore
/// use rustwarden_fediverse::services::remote_personal_access_token_service::{
///     RemotePersonalAccessTokenService, TOKEN_PREFIX,
/// };
///
/// // Issue a PAT for a remote user authenticated via SSO.
/// let (model, plaintext) = RemotePersonalAccessTokenService::<MyScope>::create(
///     &db, remote_id, "deploy", &[MyScope::Write], None,
/// ).await?;
/// assert!(plaintext.starts_with(TOKEN_PREFIX));
///
/// // Consumer-side dispatch on prefix.
/// if plaintext.starts_with(TOKEN_PREFIX) {
///     let _ = RemotePersonalAccessTokenService::<MyScope>::verify(&db, &plaintext).await?;
/// }
/// ```
pub struct RemotePersonalAccessTokenService<S: TokenScope> {
    _phantom: PhantomData<S>,
}

impl<S: TokenScope> RemotePersonalAccessTokenService<S> {
    /// Issue a new PAT for a remote identity. Returns the persisted
    /// row and the **plaintext** token (one-time display).
    ///
    /// # Errors
    ///
    /// - [`AppError::NotFound`] if `remote_identity_id` does not exist.
    /// - [`AppError::Validation`] if `name` is empty or > 64 chars, or
    ///   if the remote identity is disabled.
    /// - [`AppError::Database`] on persistence error.
    pub async fn create(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
        name: &str,
        scopes: &[S],
        expires_at: Option<DateTime<Utc>>,
    ) -> Result<(fediverse_personal_access_token::Model, String), AppError> {
        let trimmed = name.trim();
        if trimmed.is_empty() {
            return Err(AppError::Validation(
                "Token name cannot be empty".to_string(),
            ));
        }
        if trimmed.chars().count() > MAX_NAME_LEN {
            return Err(AppError::Validation(format!(
                "Token name must be {MAX_NAME_LEN} characters or fewer"
            )));
        }

        let identity = remote_identity::Entity::find_by_id(remote_identity_id)
            .one(db)
            .await?
            .ok_or_else(|| {
                AppError::NotFound(format!("remote_identity {remote_identity_id} not found"))
            })?;

        if identity.disabled_at.is_some() {
            return Err(AppError::Validation(
                "Cannot issue a PAT for a disabled remote identity".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 = fediverse_personal_access_token::ActiveModel {
            id: Set(Uuid::new_v4()),
            remote_identity_id: Set(remote_identity_id),
            name: Set(trimmed.to_string()),
            token_hash: Set(token_hash),
            scopes: Set(scope_strings),
            expires_at: Set(expires_at),
            last_used_at: NotSet,
            created_at: Set(Utc::now()),
            revoked_at: NotSet,
        };

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

    /// Verify a plaintext token. Returns `Some((model, scopes))` if the
    /// token is valid, active and its owning remote identity is not
    /// disabled. Returns `None` for any failure path uniformly, to
    /// avoid leaking the reason. Touches `last_used_at` on success.
    ///
    /// # Errors
    ///
    /// - [`AppError::Database`] on DB error. All other failures map to
    ///   `Ok(None)`.
    pub async fn verify(
        db: &DatabaseConnection,
        plaintext: &str,
    ) -> Result<Option<(fediverse_personal_access_token::Model, Vec<S>)>, AppError> {
        // 1. Format check (fast reject before any DB lookup).
        let Some(bare) = plaintext.strip_prefix(TOKEN_PREFIX) else {
            return Ok(None);
        };
        if bare.len() != 64 || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
            return Ok(None);
        }

        // 2. Hash + DB lookup.
        let candidate_hash = hash_token(plaintext);
        let Some(row) = fediverse_personal_access_token::Entity::find()
            .filter(fediverse_personal_access_token::Column::TokenHash.eq(&candidate_hash))
            .one(db)
            .await?
        else {
            return Ok(None);
        };

        // 3. Constant-time re-check (defends against DB-side comparison
        //    being variable-time).
        if !bool::from(row.token_hash.as_bytes().ct_eq(candidate_hash.as_bytes())) {
            return Ok(None);
        }

        // 4. Lifecycle checks on the token itself.
        if !row.is_active() {
            return Ok(None);
        }

        // 5. Owning remote identity must still be active.
        let Some(identity) = remote_identity::Entity::find_by_id(row.remote_identity_id)
            .one(db)
            .await?
        else {
            return Ok(None);
        };
        if identity.disabled_at.is_some() {
            return Ok(None);
        }

        // 6. Best-effort last_used_at update.
        let id = row.id;
        let mut touched: fediverse_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,
                "fediverse PAT last_used_at update failed: {}", e
            );
        }

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

    /// Revoke a PAT by id (soft-revoke: sets `revoked_at`, keeps row
    /// for audit). Idempotent — revoking an already-revoked or
    /// non-existing token returns `false`, never errors.
    ///
    /// # Errors
    ///
    /// - [`AppError::Database`] on persistence error.
    pub async fn revoke(db: &DatabaseConnection, token_id: Uuid) -> Result<bool, AppError> {
        let Some(row) = fediverse_personal_access_token::Entity::find_by_id(token_id)
            .one(db)
            .await?
        else {
            return Ok(false);
        };

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

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

    /// List all non-revoked PATs owned by a remote identity (most
    /// recent first). Empty vector if none.
    pub async fn list_for_remote_identity(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
    ) -> Result<Vec<fediverse_personal_access_token::Model>, AppError> {
        let rows = fediverse_personal_access_token::Entity::find()
            .filter(
                fediverse_personal_access_token::Column::RemoteIdentityId.eq(remote_identity_id),
            )
            .filter(fediverse_personal_access_token::Column::RevokedAt.is_null())
            .order_by_desc(fediverse_personal_access_token::Column::CreatedAt)
            .all(db)
            .await?;
        Ok(rows)
    }

    /// Bulk-revoke every PAT owned by a remote identity. Designed to be
    /// called from the `Delete{Actor}` cascade — returns the number of
    /// rows updated (rows already revoked are skipped).
    pub async fn revoke_all_for_remote_identity(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
    ) -> Result<u64, AppError> {
        let now = Utc::now();
        let result = fediverse_personal_access_token::Entity::update_many()
            .col_expr(
                fediverse_personal_access_token::Column::RevokedAt,
                sea_orm::sea_query::Expr::value(now),
            )
            .filter(
                fediverse_personal_access_token::Column::RemoteIdentityId.eq(remote_identity_id),
            )
            .filter(fediverse_personal_access_token::Column::RevokedAt.is_null())
            .exec(db)
            .await?;
        Ok(result.rows_affected)
    }
}

/// Generate a fresh prefixed token: `rwfed_pat_<64 hex chars>`.
fn generate_token_plain() -> String {
    let mut bytes = [0u8; 32];
    rand::rng().fill_bytes(&mut bytes);
    format!("{TOKEN_PREFIX}{}", 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 sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};

    #[derive(Clone, Debug, PartialEq, Eq)]
    enum TestScope {
        Read,
        Write,
    }

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

    fn remote_row(disabled: bool) -> remote_identity::Model {
        let now = Utc::now();
        remote_identity::Model {
            id: Uuid::new_v4(),
            ap_id: "https://a.tld/users/alice".to_string(),
            username: "alice".to_string(),
            domain: "a.tld".to_string(),
            display_name: None,
            summary_html: None,
            inbox_url: "https://a.tld/inbox".to_string(),
            shared_inbox_url: None,
            public_key_pem: String::new(),
            icon_url: None,
            actor_type: "Person".to_string(),
            raw_json: None,
            last_fetched_at: now,
            disabled_at: if disabled { Some(now) } else { None },
            created_at: now,
            updated_at: now,
        }
    }

    fn pat_row(
        remote_id: Uuid,
        hash: &str,
        scopes: Vec<String>,
        revoked: bool,
    ) -> fediverse_personal_access_token::Model {
        let now = Utc::now();
        fediverse_personal_access_token::Model {
            id: Uuid::new_v4(),
            remote_identity_id: remote_id,
            name: "test".to_string(),
            token_hash: hash.to_string(),
            scopes,
            expires_at: None,
            last_used_at: None,
            created_at: now,
            revoked_at: if revoked { Some(now) } else { None },
        }
    }

    // ─── Pure helpers ────────────────────────────────────────────────

    #[test]
    fn generate_token_plain_has_prefix_and_correct_length() {
        let t = generate_token_plain();
        assert!(t.starts_with(TOKEN_PREFIX));
        let bare = t.trim_start_matches(TOKEN_PREFIX);
        assert_eq!(bare.len(), 64);
        assert!(bare.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn hash_token_is_deterministic_and_64_hex() {
        let h1 = hash_token("rwfed_pat_abc");
        let h2 = hash_token("rwfed_pat_abc");
        assert_eq!(h1, h2);
        assert_eq!(h1.len(), 64);
        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
    }

    // ─── create() ────────────────────────────────────────────────────

    #[tokio::test]
    async fn create_rejects_empty_name() {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = RemotePersonalAccessTokenService::<TestScope>::create(
            &db,
            Uuid::new_v4(),
            "  ",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap_err();
        assert!(matches!(err, AppError::Validation(_)));
    }

    #[tokio::test]
    async fn create_rejects_name_too_long() {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let long = "a".repeat(65);
        let err = RemotePersonalAccessTokenService::<TestScope>::create(
            &db,
            Uuid::new_v4(),
            &long,
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap_err();
        assert!(matches!(err, AppError::Validation(_)));
    }

    #[tokio::test]
    async fn create_rejects_unknown_remote_identity() {
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<remote_identity::Model>::new()])
            .into_connection();
        let err = RemotePersonalAccessTokenService::<TestScope>::create(
            &db,
            Uuid::new_v4(),
            "deploy",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap_err();
        assert!(matches!(err, AppError::NotFound(_)));
    }

    #[tokio::test]
    async fn create_rejects_disabled_remote_identity() {
        let row = remote_row(true);
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let err = RemotePersonalAccessTokenService::<TestScope>::create(
            &db,
            Uuid::new_v4(),
            "deploy",
            &[TestScope::Read],
            None,
        )
        .await
        .unwrap_err();
        assert!(matches!(err, AppError::Validation(_)));
    }

    #[tokio::test]
    async fn create_inserts_and_returns_plaintext() {
        let row = remote_row(false);
        let rid = row.id;
        let inserted = pat_row(rid, &"a".repeat(64), vec!["read".to_string()], false);
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![inserted.clone()]])
            .into_connection();
        let (model, plain) = RemotePersonalAccessTokenService::<TestScope>::create(
            &db,
            rid,
            "deploy",
            &[TestScope::Read],
            None,
        )
        .await
        .expect("create should succeed");
        assert!(plain.starts_with(TOKEN_PREFIX));
        assert_eq!(plain.len(), TOKEN_PREFIX.len() + 64);
        assert_eq!(model.remote_identity_id, rid);
    }

    // ─── verify() ────────────────────────────────────────────────────

    #[tokio::test]
    async fn verify_rejects_missing_prefix() {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, "no_prefix_token")
            .await
            .unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn verify_rejects_bad_length() {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let result = RemotePersonalAccessTokenService::<TestScope>::verify(
            &db,
            &format!("{TOKEN_PREFIX}deadbeef"),
        )
        .await
        .unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn verify_rejects_non_hex() {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let bogus: String = std::iter::repeat_n('z', 64).collect();
        let result = RemotePersonalAccessTokenService::<TestScope>::verify(
            &db,
            &format!("{TOKEN_PREFIX}{bogus}"),
        )
        .await
        .unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn verify_returns_none_when_hash_unknown() {
        let plain = generate_token_plain();
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<fediverse_personal_access_token::Model>::new()])
            .into_connection();
        let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn verify_returns_none_when_revoked() {
        let plain = generate_token_plain();
        let hash = hash_token(&plain);
        let remote_id = Uuid::new_v4();
        let row = pat_row(remote_id, &hash, vec!["read".to_string()], true);
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn verify_returns_none_when_remote_identity_disabled() {
        let plain = generate_token_plain();
        let hash = hash_token(&plain);
        let identity = remote_row(true);
        let row = pat_row(identity.id, &hash, vec!["read".to_string()], false);
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![identity]])
            .into_connection();
        let result = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn verify_returns_some_when_active() {
        let plain = generate_token_plain();
        let hash = hash_token(&plain);
        let identity = remote_row(false);
        let row = pat_row(
            identity.id,
            &hash,
            vec!["read".to_string(), "write".to_string()],
            false,
        );
        let updated = row.clone();
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![identity]])
            .append_query_results([vec![updated]])
            .into_connection();
        let (model, scopes) = RemotePersonalAccessTokenService::<TestScope>::verify(&db, &plain)
            .await
            .unwrap()
            .expect("verify should yield a valid match");
        assert_eq!(scopes, vec![TestScope::Read, TestScope::Write]);
        assert_eq!(model.token_hash, hash);
    }

    // ─── revoke() ────────────────────────────────────────────────────

    #[tokio::test]
    async fn revoke_returns_false_when_token_missing() {
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<fediverse_personal_access_token::Model>::new()])
            .into_connection();
        let revoked = RemotePersonalAccessTokenService::<TestScope>::revoke(&db, Uuid::new_v4())
            .await
            .unwrap();
        assert!(!revoked);
    }

    #[tokio::test]
    async fn revoke_is_idempotent_on_already_revoked() {
        let row = pat_row(Uuid::new_v4(), &"a".repeat(64), vec![], true);
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let revoked = RemotePersonalAccessTokenService::<TestScope>::revoke(&db, Uuid::new_v4())
            .await
            .unwrap();
        assert!(!revoked);
    }

    #[tokio::test]
    async fn revoke_marks_token_revoked() {
        let row = pat_row(Uuid::new_v4(), &"a".repeat(64), vec![], false);
        let updated = row.clone();
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![updated]])
            .into_connection();
        let revoked = RemotePersonalAccessTokenService::<TestScope>::revoke(&db, Uuid::new_v4())
            .await
            .unwrap();
        assert!(revoked);
    }

    // ─── list / bulk revoke ──────────────────────────────────────────

    #[tokio::test]
    async fn list_for_remote_identity_returns_empty_when_none() {
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<fediverse_personal_access_token::Model>::new()])
            .into_connection();
        let rows = RemotePersonalAccessTokenService::<TestScope>::list_for_remote_identity(
            &db,
            Uuid::new_v4(),
        )
        .await
        .unwrap();
        assert!(rows.is_empty());
    }

    #[tokio::test]
    async fn revoke_all_returns_zero_when_no_rows() {
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 0,
            }])
            .into_connection();
        let n = RemotePersonalAccessTokenService::<TestScope>::revoke_all_for_remote_identity(
            &db,
            Uuid::new_v4(),
        )
        .await
        .unwrap();
        assert_eq!(n, 0);
    }

    #[tokio::test]
    async fn revoke_all_returns_count_when_rows_affected() {
        let db = MockDatabase::new(DatabaseBackend::Postgres)
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 3,
            }])
            .into_connection();
        let n = RemotePersonalAccessTokenService::<TestScope>::revoke_all_for_remote_identity(
            &db,
            Uuid::new_v4(),
        )
        .await
        .unwrap();
        assert_eq!(n, 3);
    }
}