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