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:?}");
    }
}