auth_routes.rs
750 lignes · 28917 octets
//! HTTP routes for the AP-native authentication flow (RFC 0013). //! //! Five thin handlers around [`crate::services::fediverse_auth_service::FediverseAuthService`]: //! //! | Path | Verb | Caller side | //! |---|---|---| //! | `/api/v1/auth/login/fediverse/initiate` | POST | client (`b.tld`) | //! | `/api/v1/auth/login/fediverse/status/{id}` | GET | client | //! | `/api/v1/auth/login/fediverse/consume/{id}` | POST | client | //! | `/fediverse/auth/inbox` | POST | IdP (`a.tld`) | //! | `/api/v1/fediverse/auth/consent` | POST | IdP | //! use std::sync::Arc; use std::time::Duration; use axum::{ Extension, Json, Router, extract::Path, http::{HeaderMap, StatusCode, header}, routing::{get, post}, }; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::error::FediverseError; use super::state::FediverseState; use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; #[cfg(test)] use crate::services::fediverse_auth_service::ChallengeStatus; use crate::services::fediverse_auth_service::{ ChallengeDecision, FediverseAuthService, InitiateRequest, }; use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; use rustwarden_core::auth::jwt::Claims; use rustwarden_core::config::auth::AuthConfig; /// Per-router state injected into the auth-flow handlers. #[derive(Clone)] pub struct AuthFlowState { /// Same `AuthConfig` as the local login flow — used to mint the /// remote JWT on `consume`. pub auth_config: Arc<AuthConfig>, /// Pluggable signature verifier — used by `auth/inbox` to gate /// inbound `AuthRequest` activities. pub verifier: Arc<dyn SignatureVerifier>, /// TTL of every freshly-initiated challenge. pub challenge_ttl: Duration, } /// `POST /api/v1/auth/login/fediverse/initiate` body. #[derive(Debug, Deserialize)] pub struct InitiateBody { /// `@user@domain` handle the user typed. pub handle: String, /// Optional URL to redirect to after successful consume. pub redirect_url: Option<String>, } /// `POST /api/v1/auth/login/fediverse/initiate` 201 response. #[derive(Debug, Serialize)] pub struct InitiateResponse { /// Stable challenge identifier. pub challenge_id: Uuid, /// Cryptographic nonce embedded in the AP `AuthRequest` activity. pub nonce: String, /// RFC3339 expiry timestamp. pub expires_at: String, } /// `GET /api/v1/auth/login/fediverse/status/{id}` 200 response. #[derive(Debug, Serialize)] pub struct StatusResponse { /// Same identifier as the request. pub challenge_id: Uuid, /// Wire form (`pending`, `approved`, `rejected`, `expired`, `consumed`). pub status: String, } /// `POST /api/v1/auth/login/fediverse/consume/{id}` 200 response. #[derive(Debug, Serialize)] pub struct ConsumeResponse { /// 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, } /// `POST /api/v1/fediverse/auth/consent` body. #[derive(Debug, Deserialize)] pub struct ConsentBody { /// Challenge being decided on. pub challenge_id: Uuid, /// `"approve"` or `"reject"`. pub decision: String, /// On approve only: which `remote_identities.id` is bound to the /// IdP's user that clicked approve. The handler trusts this value /// because the request itself is authenticated as the IdP user /// (CSRF-protected by the consumer middleware). pub remote_identity_id: Option<Uuid>, } /// Validate a user-supplied redirect URL against the local instance /// domain (ANSSI F-NET-2). /// /// Accepts only `http://` or `https://` schemes and rejects any host /// that does not match `allowed_host` (case-insensitive). This blocks /// open-redirect attacks (`javascript:`, `data:`, cross-domain /// phishing) before the value is persisted into /// `fediverse_auth_challenges.redirect_url`. fn validate_redirect_url(url_str: &str, allowed_host: &str) -> Result<(), FediverseError> { let parsed = url::Url::parse(url_str) .map_err(|e| FediverseError::BadRequest(format!("invalid redirect_url: {e}")))?; match parsed.scheme() { "http" | "https" => {} other => { return Err(FediverseError::BadRequest(format!( "redirect_url scheme {other:?} not allowed (use http or https)" ))); } } let host = parsed.host_str().unwrap_or("").to_ascii_lowercase(); if host != allowed_host.to_ascii_lowercase() { return Err(FediverseError::BadRequest(format!( "redirect_url host {host:?} differs from this instance domain" ))); } Ok(()) } // ─── Handlers ──────────────────────────────────────────────────────── async fn initiate( Extension(state): Extension<AuthFlowState>, Extension(fediverse_state): Extension<FediverseState>, Extension(db): Extension<DatabaseConnection>, observer: Option<Extension<Arc<dyn FediverseEventObserver>>>, Json(body): Json<InitiateBody>, ) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> { let observer: Arc<dyn FediverseEventObserver> = match observer { Some(Extension(obs)) => obs, None => Arc::new(NoopFediverseObserver), }; // ANSSI F-NET-2 — reject arbitrary `javascript:`, `data:`, or // cross-domain redirect URLs before they hit the database. The // accepted form is `http(s)://<this instance domain>/...`. if let Some(redirect) = body.redirect_url.as_deref() { validate_redirect_url(redirect, fediverse_state.config.domain())?; } let handle = body.handle.clone(); let challenge = FediverseAuthService::initiate( &db, InitiateRequest { handle: body.handle, redirect_url: body.redirect_url, }, state.challenge_ttl, ) .await?; // RFC 0028 — feed every accepted auth-challenge initiation to the // guard so the auth-spam detector can count high-volume bursts from // the same target domain (e.g. credential-stuffing the IdP). let domain = handle .rsplit_once('@') .map(|(_, d)| d.to_ascii_lowercase()) .unwrap_or_default(); observer.on_auth_challenge(&domain, &handle).await; Ok(( StatusCode::CREATED, Json(InitiateResponse { challenge_id: challenge.id, nonce: challenge.nonce, expires_at: challenge.expires_at.to_rfc3339(), }), )) } async fn status( Extension(_state): Extension<AuthFlowState>, Extension(db): Extension<DatabaseConnection>, Path(challenge_id): Path<Uuid>, ) -> Result<Json<StatusResponse>, FediverseError> { let model = FediverseAuthService::status(&db, challenge_id).await?; Ok(Json(StatusResponse { challenge_id: model.id, status: model.status, })) } async fn consume( Extension(state): Extension<AuthFlowState>, Extension(db): Extension<DatabaseConnection>, Path(challenge_id): Path<Uuid>, ) -> Result<Json<ConsumeResponse>, FediverseError> { let session = FediverseAuthService::consume(&db, &state.auth_config, challenge_id).await?; Ok(Json(ConsumeResponse { jwt: session.jwt, remote_identity_id: session.remote_identity_id, handle: session.handle, })) } async fn auth_inbox( Extension(state): Extension<AuthFlowState>, Extension(db): Extension<DatabaseConnection>, headers: HeaderMap, body: axum::body::Bytes, ) -> Result<StatusCode, FediverseError> { let signature = headers .get(header::HeaderName::from_static("signature")) .and_then(|v| v.to_str().ok()) .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".into()))?; // Parse the body once — we need both the actor (for verification) // and the full payload (for `persist_inbound_request` below). let body_json = serde_json::from_slice::<serde_json::Value>(&body) .map_err(|e| FediverseError::BadRequest(format!("malformed AuthRequest JSON: {e}")))?; let actor = body_json .get("actor") .and_then(|v| v.as_str()) .map(str::to_string) .unwrap_or_default(); let _ = signature; // presence already enforced above // F8 / RFC 0027 — enforce trust policy BEFORE signature verification // (and before any actor fetch). A hostile but signature-valid // instance must be rejected here based on its domain alone. let source_domain = url::Url::parse(&actor) .ok() .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) .assert_domain_allowed(&source_domain) .await .map_err(|e| FediverseError::Forbidden(e.to_string()))?; state .verifier .verify(&VerifyCtx { actor_ap_id: &actor, method: "post", path: "/fediverse/auth/inbox", headers: &headers, body: &body, public_key_pem: "", }) .await .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; // Dispatch on the activity `type` (RFC 0025). // // - `AuthRequest` → IdP side: persist a Pending challenge tied to // the requester (RFC 0006 Phase 2). // - `Accept`/`Reject` (with `object.type == AuthRequest`) → SP side: // propagate the IdP's decision into the local challenge (RFC 0025). // - Anything else → AP §7 says ignore unknown types silently; we // return 202 and log to ease debugging. let activity_type = body_json .get("type") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); match activity_type.as_str() { "AuthRequest" => { // Resolve this IdP's canonical domain from the env (set by // the federation router builder at app boot) for the target-validation // check. Strip scheme/trailing-slash so the comparison against // `target`'s bare host part inside `persist_inbound_request` // works whether FEDIVERSE_DOMAIN was set as `example.org` or // `http://example.org`. (TODO: thread the parsed // FediverseConfig in via extension to avoid env reads here.) let idp_domain = std::env::var("FEDIVERSE_DOMAIN") .unwrap_or_default() .trim_start_matches("http://") .trim_start_matches("https://") .trim_end_matches('/') .to_ascii_lowercase(); FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) .await .map_err(|e| FediverseError::BadRequest(e.to_string()))?; } "Accept" | "Reject" => { FediverseAuthService::record_inbound_decision(&db, &body_json) .await .map_err(|e| FediverseError::BadRequest(e.to_string()))?; } other => { tracing::debug!( activity_type = other, "auth_inbox: ignoring unknown activity type" ); } } Ok(StatusCode::ACCEPTED) } async fn consent( Extension(_state): Extension<AuthFlowState>, Extension(claims): Extension<Claims>, Extension(db): Extension<DatabaseConnection>, Json(body): Json<ConsentBody>, ) -> Result<StatusCode, FediverseError> { // ANSSI F-NET-4 — require an authenticated Bearer JWT on this // JSON-API endpoint. Cookie-only callers cannot reach it, which // structurally eliminates the CSRF surface on this consent flow // (the UI counterpart in rustwarden-fediverse-ui already enforces // CSRF via `csrf::validate_csrf_token`). tracing::info!( user_id = %claims.sub, challenge_id = %body.challenge_id, decision = %body.decision, "fediverse consent recorded (api)", ); let decision = match body.decision.as_str() { "approve" => { let remote = body.remote_identity_id.ok_or_else(|| { FediverseError::BadRequest("approve requires remote_identity_id".into()) })?; ChallengeDecision::Approve { remote_identity_id: remote, } } "reject" => ChallengeDecision::Reject, other => { return Err(FediverseError::BadRequest(format!( "invalid decision '{other}'", ))); } }; FediverseAuthService::record_decision(&db, body.challenge_id, decision).await?; Ok(StatusCode::OK) } /// Build the auth-flow router. Mounted by /// [`crate::routes::router_with`] alongside the discovery /// + lifecycle inbox routes. pub fn router(state: AuthFlowState) -> Router<DatabaseConnection> { Router::new() .route("/api/v1/auth/login/fediverse/initiate", post(initiate)) .route( "/api/v1/auth/login/fediverse/status/{challenge_id}", get(status), ) .route( "/api/v1/auth/login/fediverse/consume/{challenge_id}", post(consume), ) .route("/fediverse/auth/inbox", post(auth_inbox)) .route("/api/v1/fediverse/auth/consent", post(consent)) .layer(Extension(state)) } #[cfg(test)] mod tests { use super::*; use crate::models::fediverse_auth_challenge; use async_trait::async_trait; use axum::body::Body; use axum::http::Request; use chrono::{Duration as ChronoDuration, Utc}; use rustwarden_core::error::AppError; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; use tower::ServiceExt; /// F8 enforcement helper for unit tests: pre-queues three mock /// results (app_settings.fediverse_enabled=true, /// fediverse_trust_mode=open_with_blocklist, empty blocklist) so /// `FediverseTrustService::assert_domain_allowed` answers Ok for /// any well-formed actor URL. fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { use crate::models::fediverse_blocked_domain; use rustwarden_core::models::app_settings; let now = Utc::now(); mock.append_query_results([vec![app_settings::Model { id: Uuid::new_v4(), key: "fediverse_enabled".to_string(), value: "true".to_string(), description: None, updated_by: None, updated_at: now, }]]) .append_query_results([vec![app_settings::Model { id: Uuid::new_v4(), key: "fediverse_trust_mode".to_string(), value: "open_with_blocklist".to_string(), description: None, updated_by: None, updated_at: now, }]]) .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) } 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, } } struct AlwaysAccept; #[async_trait] impl SignatureVerifier for AlwaysAccept { async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { Ok(()) } } /// Stub `LocalUserResolver` returning `Ok(None)` for every lookup /// — sufficient for the auth_routes tests that never resolve a /// local user (initiate / status / consume operate on challenges /// keyed by remote identity). struct EmptyResolver; #[async_trait] impl crate::resolver::LocalUserResolver for EmptyResolver { async fn find_local_user( &self, _username: &str, ) -> Result<Option<crate::resolver::UserSummary>, AppError> { Ok(None) } async fn count_users_total(&self) -> Result<u64, AppError> { Ok(0) } async fn count_users_active_since(&self, _days: u32) -> Result<u64, AppError> { Ok(0) } async fn count_local_posts(&self) -> Result<u64, AppError> { Ok(0) } } /// Build a stub `Claims` to satisfy handlers that extract one (the /// `consent` route in particular). Auth middleware doesn't run in /// unit tests, so we synthesise the extension via a layer below. fn test_claims() -> Claims { Claims { sub: Uuid::new_v4(), jti: String::new(), aud: String::new(), username: "tester".into(), roles: vec!["admin".into()], permissions: vec![], exp: 0, iat: 0, iss: "rustwarden-test".into(), identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local, remote_id: None, } } fn build_router(db: DatabaseConnection) -> Router { let state = AuthFlowState { auth_config: Arc::new(test_auth_config()), verifier: Arc::new(AlwaysAccept), challenge_ttl: Duration::from_secs(300), }; let fediverse_state = FediverseState { config: crate::config::FediverseConfig::for_tests("test.local"), resolver: Arc::new(EmptyResolver), }; router(state) .layer(Extension(db)) .layer(Extension(fediverse_state)) .layer(Extension(test_claims())) .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection()) } fn fresh_challenge(id: Uuid) -> fediverse_auth_challenge::Model { let now = Utc::now(); fediverse_auth_challenge::Model { id, nonce: "nonce-xxx".into(), handle: "alice@a.tld".into(), domain: "a.tld".into(), requested_remote_id: None, status: ChallengeStatus::Pending.as_str().into(), redirect_url: None, created_at: now, expires_at: now + ChronoDuration::minutes(5), consumed_at: None, } } fn req_post(uri: &str, body: &str) -> Request<Body> { Request::builder() .uri(uri) .method("POST") .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap() } fn req_get(uri: &str) -> Request<Body> { Request::builder() .uri(uri) .method("GET") .body(Body::empty()) .unwrap() } // ─── initiate ──────────────────────────────────────────────────── #[tokio::test] async fn initiate_returns_201_with_challenge_id() { let row = fresh_challenge(Uuid::new_v4()); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let body = serde_json::json!({ "handle": "alice@a.tld", }) .to_string(); let resp = build_router(db) .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); } #[tokio::test] async fn initiate_returns_400_for_invalid_handle() { let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let body = serde_json::json!({ "handle": "no-at-sign", }) .to_string(); let resp = build_router(db) .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } // ─── status ────────────────────────────────────────────────────── #[tokio::test] async fn status_returns_pending_for_fresh_challenge() { let id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![fresh_challenge(id)]]) .into_connection(); let resp = build_router(db) .oneshot(req_get(&format!( "/api/v1/auth/login/fediverse/status/{id}" ))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn status_returns_404_for_unknown_id() { let id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<fediverse_auth_challenge::Model>::new()]) .into_connection(); let resp = build_router(db) .oneshot(req_get(&format!( "/api/v1/auth/login/fediverse/status/{id}" ))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // ─── consume ───────────────────────────────────────────────────── #[tokio::test] async fn consume_returns_jwt_for_approved_challenge() { let id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let mut row = fresh_challenge(id); row.status = ChallengeStatus::Approved.as_str().into(); row.requested_remote_id = Some(remote_id); let mut after = row.clone(); after.status = ChallengeStatus::Consumed.as_str().into(); 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 resp = build_router(db) .oneshot(req_post( &format!("/api/v1/auth/login/fediverse/consume/{id}"), "", )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn consume_returns_400_when_pending() { let id = Uuid::new_v4(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![fresh_challenge(id)]]) .into_connection(); let resp = build_router(db) .oneshot(req_post( &format!("/api/v1/auth/login/fediverse/consume/{id}"), "", )) .await .unwrap(); // Validation error from the service maps to 400 via AppError. assert!( resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, "expected 400/422, got {}", resp.status() ); } // ─── auth_inbox ────────────────────────────────────────────────── #[tokio::test] async fn auth_inbox_returns_400_when_requester_actor_unknown() { // Phase 2 (RFC 0006) enriches `auth_inbox` to persist a // challenge bound to the requester's `remote_identities.id`. // When the row is absent the service returns Validation, which // maps to a 400 BadRequest — NOT a 202. let body = serde_json::json!({ "type": "AuthRequest", "actor": "https://unknown.tld", "target": "alice@a.tld", "nonce": "n", }) .to_string(); let req = Request::builder() .uri("/fediverse/auth/inbox") .method("POST") .header("signature", "keyId=...") .header("content-type", "application/activity+json") .body(Body::from(body)) .unwrap(); // Empty remote_identities cache → persist_inbound_request rejects. // F8 needs permissive trust mock prepended so enforcement passes // and the BadRequest assertion catches the persist_inbound_request // path, not the trust gate. // // We deliberately do NOT mutate FEDIVERSE_DOMAIN here: race // condition risk with concurrent build_*_payload tests that read // env via FediverseConfig::from_env(). Whatever value is in env // (or none), the assertion still holds: either the target domain // mismatches and persist_inbound_request returns BadRequest, or // the remote_identities lookup misses and also returns BadRequest. let db: DatabaseConnection = permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) .into_connection(); let resp = build_router(db).oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn auth_inbox_returns_401_when_signature_missing() { let req = Request::builder() .uri("/fediverse/auth/inbox") .method("POST") .body(Body::from("{}")) .unwrap(); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let resp = build_router(db).oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } // ─── consent ───────────────────────────────────────────────────── #[tokio::test] async fn consent_approve_marks_challenge_approved() { let id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let row = fresh_challenge(id); let mut after = row.clone(); after.status = ChallengeStatus::Approved.as_str().into(); 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 body = serde_json::json!({ "challenge_id": id, "decision": "approve", "remote_identity_id": remote_id, }) .to_string(); let resp = build_router(db) .oneshot(req_post("/api/v1/fediverse/auth/consent", &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn consent_reject_marks_challenge_rejected() { let id = Uuid::new_v4(); let row = fresh_challenge(id); let mut after = row.clone(); after.status = ChallengeStatus::Rejected.as_str().into(); 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 body = serde_json::json!({ "challenge_id": id, "decision": "reject", }) .to_string(); let resp = build_router(db) .oneshot(req_post("/api/v1/fediverse/auth/consent", &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } }
GitRust