fediverse_auth_service.rs
950 lignes · 36845 octets
//! AP-native authentication flow service (RFC 0006). //! //! Orchestrates the four-step challenge state machine on the //! client-side (`b.tld`): //! //! 1. `initiate` — persist a fresh `pending` challenge with a 256-bit //! cryptographic nonce. //! 2. `record_decision` — IdP-side approve/reject promotes the row. //! 3. `status` — caller polls; expired rows transition to `Expired`. //! 4. `consume` — mint a remote JWT from an `Approved` challenge, //! flipping the row to `Consumed` (one-shot). //! use base64::Engine; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use rand::RngCore; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; use std::time::Duration; use uuid::Uuid; use crate::config::FediverseConfig; use crate::models::fediverse_auth_challenge; use rustwarden_core::auth::jwt::JwtService; use rustwarden_core::config::auth::AuthConfig; use rustwarden_core::error::AppError; /// State machine for [`fediverse_auth_challenge::Model::status`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChallengeStatus { /// Awaiting IdP decision. Pending, /// IdP user clicked Approve. Approved, /// IdP user clicked Reject. Rejected, /// `expires_at` reached without decision. Expired, /// Already exchanged for a JWT (one-shot guard). Consumed, } impl ChallengeStatus { /// Wire form stored in the `status` column. pub fn as_str(self) -> &'static str { match self { Self::Pending => "pending", Self::Approved => "approved", Self::Rejected => "rejected", Self::Expired => "expired", Self::Consumed => "consumed", } } /// Best-effort parse — unknown values fall back to `Pending` so a /// data corruption never silently mints a JWT. pub fn parse(raw: &str) -> Self { match raw.trim().to_ascii_lowercase().as_str() { "approved" => Self::Approved, "rejected" => Self::Rejected, "expired" => Self::Expired, "consumed" => Self::Consumed, _ => Self::Pending, } } } /// Caller input for [`FediverseAuthService::initiate`]. #[derive(Debug, Clone)] pub struct InitiateRequest { /// `@user@domain` handle the user typed. pub handle: String, /// Optional URL the caller wants the browser redirected to after /// the JWT is minted. pub redirect_url: Option<String>, } /// Receipt returned by [`FediverseAuthService::initiate`]. #[derive(Debug, Clone)] pub struct InitiatedChallenge { /// PK of the persisted row. pub id: Uuid, /// Cryptographic nonce embedded in the AP `AuthRequest` body. pub nonce: String, /// Hard deadline. pub expires_at: DateTime<Utc>, } /// IdP-side input for [`FediverseAuthService::record_decision`]. #[derive(Debug, Clone)] pub enum ChallengeDecision { /// User approved; bind the resolved `remote_identities.id` to the /// challenge so that `consume` can mint the JWT. Approve { /// FK of the resolved remote identity bound to this approve. remote_identity_id: Uuid, }, /// User rejected. Reject, } /// Output of [`FediverseAuthService::consume`]. #[derive(Debug, Clone)] pub struct ConsumedSession { /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`). pub jwt: String, /// FK of the federated identity the JWT speaks for. pub remote_identity_id: Uuid, /// `@user@domain` handle for log/audit cohesion. pub handle: String, } /// Stateless service exposing the AP-native auth state machine. pub struct FediverseAuthService; impl FediverseAuthService { /// Step 1 — persist a `pending` challenge for the given handle. /// /// The caller is responsible for separately enqueueing the AP /// `AuthRequest` activity to the remote inbox via /// `FediverseOutboundService::enqueue` and the helper /// [`build_auth_request_payload`] below. pub async fn initiate( db: &DatabaseConnection, request: InitiateRequest, ttl: Duration, ) -> Result<InitiatedChallenge, AppError> { let domain = handle_domain(&request.handle)?; let now = Utc::now(); let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| { AppError::Internal(format!("ttl out of range for chrono::Duration: {e}")) })?; let nonce = generate_nonce(); let id = Uuid::new_v4(); let active = fediverse_auth_challenge::ActiveModel { id: Set(id), nonce: Set(nonce.clone()), handle: Set(request.handle.clone()), domain: Set(domain), requested_remote_id: Set(None), status: Set(ChallengeStatus::Pending.as_str().to_string()), redirect_url: Set(request.redirect_url), created_at: Set(now), expires_at: Set(expires_at), consumed_at: Set(None), }; let inserted = active.insert(db).await?; Ok(InitiatedChallenge { id: inserted.id, nonce: inserted.nonce, expires_at: inserted.expires_at, }) } /// Step 2 — IdP records the user's decision. pub async fn record_decision( db: &DatabaseConnection, challenge_id: Uuid, decision: ChallengeDecision, ) -> Result<fediverse_auth_challenge::Model, AppError> { let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) .one(db) .await? .ok_or_else(|| { AppError::NotFound(format!("Auth challenge {challenge_id} not found")) })?; if model.status != ChallengeStatus::Pending.as_str() { return Err(AppError::Validation(format!( "Cannot record decision: challenge is in state {}", model.status ))); } let mut active: fediverse_auth_challenge::ActiveModel = model.into(); match decision { ChallengeDecision::Approve { remote_identity_id } => { active.status = Set(ChallengeStatus::Approved.as_str().to_string()); active.requested_remote_id = Set(Some(remote_identity_id)); } ChallengeDecision::Reject => { active.status = Set(ChallengeStatus::Rejected.as_str().to_string()); } } Ok(active.update(db).await?) } /// Step 1bis (RFC 0006 Phase 2 — inbox enrichment) : called by the /// IdP-side `/fediverse/auth/inbox` handler **after** the signature /// has been verified. Parses the AP `AuthRequest` body, resolves the /// requester's `remote_identities.id` from the `actor` URL, and /// inserts a `Pending` challenge bound to that requester. /// /// Without this step, the consent screen (RFC 0023 Slice B iter. 4) /// cannot Approve : `record_decision` requires `requested_remote_id` /// which used to stay `None` because the inbox handler only /// acknowledged the activity. /// /// Returns the inserted row. The IdP UI can then show /// `/fediverse/consent/{returned.id}` to the local user. /// /// **Validation** : /// - `body.target` must equal `@<local_user>@<idp_domain>` — /// otherwise `AppError::Validation`. /// - `body.actor` must point to an existing `remote_identities` row — /// the row is created upstream by the signature verifier /// ([`crate::http_signatures::HttpSigVerifier::with_resolver`]) /// when the actor doc is fetched the first time. pub async fn persist_inbound_request( db: &DatabaseConnection, body: &serde_json::Value, idp_domain: &str, ) -> Result<fediverse_auth_challenge::Model, AppError> { use crate::models::remote_identity; use sea_orm::QueryFilter; let target = body .get("target") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("AuthRequest body missing `target`".into()))? .to_string(); let actor_url = body .get("actor") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("AuthRequest body missing `actor`".into()))? .to_string(); let nonce = body .get("nonce") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("AuthRequest body missing `nonce`".into()))? .to_string(); // Validate target domain matches the IdP — otherwise the request // is misrouted and we refuse rather than create a stray challenge. let target_domain = handle_domain(&target)?; if target_domain != idp_domain.to_ascii_lowercase() { return Err(AppError::Validation(format!( "AuthRequest target domain `{target_domain}` does not match this IdP `{idp_domain}`", ))); } // Resolve the requester's remote_identities row by AP id. The // signature verifier ran upstream and already required this row, // so a NotFound here means a code-ordering bug and we surface it. use sea_orm::ColumnTrait; let requester = remote_identity::Entity::find() .filter(remote_identity::Column::ApId.eq(&actor_url)) .one(db) .await? .ok_or_else(|| { AppError::Validation(format!( "Requester actor `{actor_url}` not found in remote_identities cache; \ signature verifier should have populated this row", )) })?; // Parse `expiresAt` (RFC3339); fallback to default TTL if absent // or malformed (5 min, RFC 0006 §6). let expires_at = body .get("expiresAt") .and_then(|v| v.as_str()) .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|| Utc::now() + ChronoDuration::minutes(5)); let id = Uuid::new_v4(); let active = fediverse_auth_challenge::ActiveModel { id: Set(id), nonce: Set(nonce), handle: Set(target), // Domain stored is the requester's domain (b.tld) — same // semantic as the client-side `initiate` row stores // `b.tld` for the IdP it's calling. Symmetrical. domain: Set(requester.domain.clone()), requested_remote_id: Set(Some(requester.id)), status: Set(ChallengeStatus::Pending.as_str().to_string()), redirect_url: Set(None), created_at: Set(Utc::now()), expires_at: Set(expires_at), consumed_at: Set(None), }; Ok(active.insert(db).await?) } /// Step 1ter (RFC 0025 — back-channel) : called by the SP-side /// `/fediverse/auth/inbox` when the IdP delivers an /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's /// `object` IRI references B's original AuthRequest; the nonce is /// the last URL segment. /// /// Security: the approver's domain (`actor`'s host) must match the /// `domain` recorded on the local challenge when it was initiated. /// Without that check, `evil.tld` could fabricate an `Accept` for a /// nonce it observed and trick B into approving it. /// /// Idempotent: replaying the same decision on a challenge that's /// already left `Pending` returns `Ok(())` without DB write. pub async fn record_inbound_decision( db: &DatabaseConnection, body: &serde_json::Value, ) -> Result<(), AppError> { let activity_type = body .get("type") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("activity missing `type`".into()))? .to_string(); let approver_actor_url = body .get("actor") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))? .to_string(); let nonce = extract_nonce_from_decision_object(body)?; use crate::models::remote_identity; use sea_orm::{ColumnTrait, QueryFilter}; let challenge = fediverse_auth_challenge::Entity::find() .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce)) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?; // Idempotence on terminal states. if challenge.status != ChallengeStatus::Pending.as_str() { return Ok(()); } if challenge.expires_at < Utc::now() { return Err(AppError::Validation(format!( "challenge {} expired before decision arrived", challenge.id ))); } // Security: the approver must live on the IdP that this // challenge originally targeted. let approver_domain = url::Url::parse(&approver_actor_url) .ok() .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) .ok_or_else(|| { AppError::Validation(format!( "approver actor URL has no host: {approver_actor_url}" )) })?; if approver_domain != challenge.domain.to_ascii_lowercase() { return Err(AppError::Validation(format!( "approver domain `{approver_domain}` does not match challenge IdP `{}`", challenge.domain ))); } // Resolve the approver in `remote_identities`. F2's verifier // populated this row on signature verification; if it's still // absent, surface the integrity issue rather than approve blindly. let approver = remote_identity::Entity::find() .filter(remote_identity::Column::ApId.eq(&approver_actor_url)) .one(db) .await? .ok_or_else(|| { AppError::Validation(format!( "approver {approver_actor_url} not in remote_identities cache; \ signature verifier should have populated this row", )) })?; let new_status = match activity_type.as_str() { "Accept" => ChallengeStatus::Approved, "Reject" => ChallengeStatus::Rejected, other => { return Err(AppError::Validation(format!( "record_inbound_decision: unsupported type `{other}` (expected Accept|Reject)" ))); } }; let mut active: fediverse_auth_challenge::ActiveModel = challenge.into(); active.status = Set(new_status.as_str().to_string()); active.requested_remote_id = Set(Some(approver.id)); active.update(db).await?; Ok(()) } /// Step 3 — caller polls. Promotes pending-but-expired rows to /// `Expired` lazily so the caller observes a stable terminal state. pub async fn status( db: &DatabaseConnection, challenge_id: Uuid, ) -> Result<fediverse_auth_challenge::Model, AppError> { let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) .one(db) .await? .ok_or_else(|| { AppError::NotFound(format!("Auth challenge {challenge_id} not found")) })?; if model.status == ChallengeStatus::Pending.as_str() && model.expires_at < Utc::now() { let mut active: fediverse_auth_challenge::ActiveModel = model.into(); active.status = Set(ChallengeStatus::Expired.as_str().to_string()); return Ok(active.update(db).await?); } Ok(model) } /// Step 4 — atomically mint a remote JWT from an `Approved` /// challenge. pub async fn consume( db: &DatabaseConnection, config: &AuthConfig, challenge_id: Uuid, ) -> Result<ConsumedSession, AppError> { let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) .one(db) .await? .ok_or_else(|| { AppError::NotFound(format!("Auth challenge {challenge_id} not found")) })?; match ChallengeStatus::parse(&model.status) { ChallengeStatus::Approved => {} ChallengeStatus::Consumed => { return Err(AppError::Validation( "Challenge already consumed".to_string(), )); } other => { return Err(AppError::Validation(format!( "Cannot consume challenge in state {}", other.as_str() ))); } } let remote_identity_id = model.requested_remote_id.ok_or_else(|| { AppError::Internal( "Approved challenge missing remote_identity_id (data integrity bug)".to_string(), ) })?; let handle = model.handle.clone(); let jwt = JwtService::generate_remote_token(config, remote_identity_id, &handle)?; let mut active: fediverse_auth_challenge::ActiveModel = model.into(); active.status = Set(ChallengeStatus::Consumed.as_str().to_string()); active.consumed_at = Set(Some(Utc::now())); active.update(db).await?; Ok(ConsumedSession { jwt, remote_identity_id, handle, }) } } /// Build the JSON-LD body of the `AuthRequest` activity (unsigned). /// /// The HTTP signature is attached at delivery time by the /// `OutboundDelivery` implementation supplied to the worker. pub fn build_auth_request_payload( config: &FediverseConfig, challenge: &InitiatedChallenge, target_handle: &str, ) -> serde_json::Value { serde_json::json!({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://schema.rustwarden.org/auth-request/v1" ], "type": "AuthRequest", "id": format!("{}auth-requests/{}", config.base_url(), challenge.id), "actor": format!("{}", config.base_url()), "target": target_handle, "nonce": challenge.nonce, "expiresAt": challenge.expires_at.to_rfc3339(), }) } /// Build the JSON-LD body of an `Accept(AuthRequest)` or /// `Reject(AuthRequest)` activity (RFC 0025). /// /// The IRI-only form of `object` would require the IdP to know B's /// AuthRequest URL — which we don't store. We use the inline-object /// form `{ type: "AuthRequest", nonce }` instead; the receiving side /// extracts the nonce via `extract_nonce_from_decision_object`. /// /// `idp_base_url` is the canonical base URL of A (the IdP, e.g. /// `http://alpha.local/`). `approver_actor_url` is the AP id of the /// local user that clicked Approve / Reject. `requester_inbox_url` is /// the inbox of B's user that initiated the AuthRequest (delivery /// target). `approve=true` emits Accept, `false` emits Reject. pub fn build_auth_decision_payload( idp_base_url: &url::Url, approver_actor_url: &str, requester_inbox_url: &str, nonce: &str, approve: bool, ) -> serde_json::Value { let activity_type = if approve { "Accept" } else { "Reject" }; let activity_id = format!("{}auth-decisions/{}", idp_base_url, Uuid::new_v4()); serde_json::json!({ "@context": [ "https://www.w3.org/ns/activitystreams", { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } ], "type": activity_type, "id": activity_id, "actor": approver_actor_url, "object": { "type": "AuthRequest", "nonce": nonce, }, "to": [ requester_inbox_url ], }) } /// Extract the AuthRequest nonce from the `object` field of an inbound /// `Accept` or `Reject` activity. Supports two payload shapes: /// - **IRI form** (canonical) : `"object": "http://b.tld/auth-requests/<nonce>"` /// — the nonce is the last URL path segment. /// - **Inline-object form** (defensive fallback) : `"object": { "nonce": "<v>" }`. fn extract_nonce_from_decision_object(body: &serde_json::Value) -> Result<String, AppError> { let object = body .get("object") .ok_or_else(|| AppError::Validation("Accept/Reject missing `object`".into()))?; if let Some(iri) = object.as_str() { let nonce = iri.rsplit('/').next().unwrap_or("").trim(); if nonce.is_empty() { return Err(AppError::Validation(format!( "cannot extract nonce from object IRI `{iri}`" ))); } return Ok(nonce.to_string()); } if let Some(inline) = object.as_object() && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str()) && !nonce.is_empty() { return Ok(nonce.to_string()); } Err(AppError::Validation( "cannot extract nonce from Accept/Reject `object`".into(), )) } fn handle_domain(handle: &str) -> Result<String, AppError> { let trimmed = handle.trim().trim_start_matches('@'); let (_, domain) = trimmed.split_once('@').ok_or_else(|| { AppError::Validation(format!("Invalid handle '{handle}': expected user@domain",)) })?; if domain.is_empty() { return Err(AppError::Validation(format!( "Invalid handle '{handle}': empty domain", ))); } Ok(domain.to_ascii_lowercase()) } fn generate_nonce() -> String { let mut buf = [0u8; 32]; rand::rng().fill_bytes(&mut buf); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) } #[cfg(test)] mod tests { use super::*; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; fn fresh_pending(id: Uuid, handle: &str) -> fediverse_auth_challenge::Model { let now = Utc::now(); fediverse_auth_challenge::Model { id, nonce: "AAAA".to_string(), handle: handle.to_string(), domain: handle.split('@').next_back().unwrap_or("").to_string(), requested_remote_id: None, status: ChallengeStatus::Pending.as_str().to_string(), redirect_url: None, created_at: now, expires_at: now + ChronoDuration::minutes(5), consumed_at: None, } } fn test_auth_config() -> AuthConfig { AuthConfig { jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" .to_string(), jwt_expiration_minutes: 15, jwt_issuer: "rustwarden-test".to_string(), refresh_token_expiration_days: 7, remember_me_expiration_days: 30, session_timeout_minutes: 30, session_backend: rustwarden_core::config::auth::SessionBackend::Memory, redis_url: None, rate_limit_login_per_minute: 5, rate_limit_refresh_per_minute: 10, rate_limit_general_per_minute: 100, cookie_secure: false, cookie_same_site: "Strict".to_string(), login_path: "/login".to_string(), auth_failure_mode: rustwarden_core::config::auth::AuthFailureMode::Redirect, } } fn test_fediverse_config() -> FediverseConfig { FediverseConfig::for_tests("test.local") } // ─── Pure helpers ─────────────────────────────────────────────── #[test] fn challenge_status_round_trips_through_string() { for s in [ ChallengeStatus::Pending, ChallengeStatus::Approved, ChallengeStatus::Rejected, ChallengeStatus::Expired, ChallengeStatus::Consumed, ] { assert_eq!(ChallengeStatus::parse(s.as_str()), s); } assert_eq!(ChallengeStatus::parse("garbage"), ChallengeStatus::Pending); } #[test] fn build_auth_request_payload_includes_nonce_and_target() { let config = test_fediverse_config(); let challenge = InitiatedChallenge { id: Uuid::new_v4(), nonce: "test-nonce-xxx".into(), expires_at: Utc::now() + ChronoDuration::minutes(5), }; let payload = build_auth_request_payload(&config, &challenge, "alice@a.tld"); assert_eq!(payload["nonce"], serde_json::json!("test-nonce-xxx")); assert_eq!(payload["target"], serde_json::json!("alice@a.tld")); assert_eq!(payload["type"], serde_json::json!("AuthRequest")); } #[test] fn build_auth_request_payload_uses_fediverse_base_url() { let config = test_fediverse_config(); let challenge = InitiatedChallenge { id: Uuid::new_v4(), nonce: "n".into(), expires_at: Utc::now(), }; let payload = build_auth_request_payload(&config, &challenge, "bob@b.tld"); let id = payload["id"].as_str().unwrap(); assert!( id.contains(config.base_url().as_str()), "id must embed base_url, got {id}", ); } // ─── DB-mocked ────────────────────────────────────────────────── #[tokio::test] async fn initiate_persists_pending_challenge_with_fresh_nonce() { let id = Uuid::new_v4(); let row = fresh_pending(id, "alice@a.tld"); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row.clone()]]) .into_connection(); let initiated = FediverseAuthService::initiate( &db, InitiateRequest { handle: "alice@a.tld".into(), redirect_url: None, }, Duration::from_secs(300), ) .await .expect("initiate ok"); assert!(!initiated.nonce.is_empty()); } #[test] fn generate_nonce_is_unique_and_url_safe() { // Direct test of the helper: the mock returns whatever we // hand it back, so guarantee the **service-side** randomness // by exercising the generator directly. let a = generate_nonce(); let b = generate_nonce(); assert_ne!(a, b); assert!( a.bytes() .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), "nonce must be URL-safe base64, got {a}", ); assert!(a.len() >= 32, "nonce length suspicious: {}", a.len()); } #[tokio::test] async fn record_decision_approve_marks_approved_and_records_remote_id() { let id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let row = fresh_pending(id, "alice@a.tld"); let mut after = row.clone(); after.status = ChallengeStatus::Approved.as_str().to_string(); after.requested_remote_id = Some(remote_id); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let updated = FediverseAuthService::record_decision( &db, id, ChallengeDecision::Approve { remote_identity_id: remote_id, }, ) .await .unwrap(); assert_eq!(updated.status, "approved"); assert_eq!(updated.requested_remote_id, Some(remote_id)); } #[tokio::test] async fn record_decision_reject_marks_rejected() { let id = Uuid::new_v4(); let row = fresh_pending(id, "alice@a.tld"); let mut after = row.clone(); after.status = ChallengeStatus::Rejected.as_str().to_string(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let updated = FediverseAuthService::record_decision(&db, id, ChallengeDecision::Reject) .await .unwrap(); assert_eq!(updated.status, "rejected"); } #[tokio::test] async fn status_marks_expired_when_past_expires_at() { let id = Uuid::new_v4(); let mut row = fresh_pending(id, "alice@a.tld"); row.expires_at = Utc::now() - ChronoDuration::minutes(1); let mut after = row.clone(); after.status = ChallengeStatus::Expired.as_str().to_string(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let returned = FediverseAuthService::status(&db, id).await.unwrap(); assert_eq!(returned.status, "expired"); } #[tokio::test] async fn status_returns_pending_when_not_decided_yet() { let id = Uuid::new_v4(); let row = fresh_pending(id, "alice@a.tld"); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let returned = FediverseAuthService::status(&db, id).await.unwrap(); assert_eq!(returned.status, "pending"); } #[tokio::test] async fn consume_rejects_pending_challenge() { let id = Uuid::new_v4(); let row = fresh_pending(id, "alice@a.tld"); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let err = FediverseAuthService::consume(&db, &test_auth_config(), id) .await .expect_err("must refuse pending"); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn consume_rejects_already_consumed_challenge() { let id = Uuid::new_v4(); let mut row = fresh_pending(id, "alice@a.tld"); row.status = ChallengeStatus::Consumed.as_str().to_string(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let err = FediverseAuthService::consume(&db, &test_auth_config(), id) .await .expect_err("must refuse consumed"); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn consume_emits_remote_jwt_for_approved_challenge() { let id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let mut row = fresh_pending(id, "alice@a.tld"); row.status = ChallengeStatus::Approved.as_str().to_string(); row.requested_remote_id = Some(remote_id); let mut after = row.clone(); after.status = ChallengeStatus::Consumed.as_str().to_string(); after.consumed_at = Some(Utc::now()); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let session = FediverseAuthService::consume(&db, &test_auth_config(), id) .await .unwrap(); assert_eq!(session.remote_identity_id, remote_id); assert_eq!(session.handle, "alice@a.tld"); assert!(!session.jwt.is_empty()); } // ─── Phase 2 (RFC 0006) — inbox enrichment ────────────────────── fn fresh_remote_identity( actor_url: &str, domain: &str, ) -> crate::models::remote_identity::Model { let now = Utc::now(); crate::models::remote_identity::Model { id: Uuid::new_v4(), ap_id: actor_url.to_string(), username: "requester".to_string(), domain: domain.to_string(), display_name: None, summary_html: None, inbox_url: format!("{actor_url}/inbox"), shared_inbox_url: None, public_key_pem: "-----BEGIN PUBLIC KEY-----\nfoo\n-----END PUBLIC KEY-----\n" .to_string(), icon_url: None, actor_type: "Person".to_string(), raw_json: None, last_fetched_at: now, disabled_at: None, created_at: now, updated_at: now, } } #[tokio::test] async fn persist_inbound_request_inserts_pending_with_remote_id() { let requester = fresh_remote_identity("https://b.tld/", "b.tld"); let requester_id = requester.id; let inserted = fediverse_auth_challenge::Model { id: Uuid::new_v4(), nonce: "n".to_string(), handle: "alice@a.tld".to_string(), domain: "b.tld".to_string(), requested_remote_id: Some(requester_id), status: ChallengeStatus::Pending.as_str().to_string(), redirect_url: None, created_at: Utc::now(), expires_at: Utc::now() + ChronoDuration::minutes(5), consumed_at: None, }; let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![requester]]) .append_query_results([vec![inserted]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let body = serde_json::json!({ "type": "AuthRequest", "actor": "https://b.tld/", "target": "@alice@a.tld", "nonce": "n", "expiresAt": (Utc::now() + ChronoDuration::minutes(5)).to_rfc3339(), }); let row = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") .await .expect("persist_inbound_request ok"); assert_eq!(row.handle, "alice@a.tld"); assert_eq!(row.domain, "b.tld"); assert_eq!(row.requested_remote_id, Some(requester_id)); } #[tokio::test] async fn persist_inbound_request_rejects_wrong_idp_domain() { let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let body = serde_json::json!({ "actor": "https://b.tld/", "target": "@alice@evil.tld", "nonce": "n", }); let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") .await .expect_err("must reject mismatched IdP domain"); assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); } #[tokio::test] async fn persist_inbound_request_rejects_missing_actor() { let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let body = serde_json::json!({ "target": "@alice@a.tld", "nonce": "n", }); let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") .await .expect_err("must reject missing actor"); assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); } #[tokio::test] async fn persist_inbound_request_rejects_unknown_requester() { // No remote_identities row matches → service surfaces a clear // Validation error rather than inserting a stray challenge. let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) .into_connection(); let body = serde_json::json!({ "actor": "https://unknown.tld/", "target": "@alice@a.tld", "nonce": "n", }); let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") .await .expect_err("must reject unknown requester"); assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); } }
GitRust