user_auth.rs
1046 lignes · 40552 octets
//! User-facing handlers for the Fediverse auth flow (RFC 0023, Slice B). //! //! Iter. 1 ships only the error landing page //! (`/login/fediverse/error?reason=…`) — the four other endpoints //! (login form, waiting page, SSE, consent) land in subsequent iters. //! //! All routes here are **public** (no `Claims` extension required) and //! gated by `experimental-fediverse`. The mode-dégradé (Plan UI §3 P9) //! returns `404` when `fediverse_enabled = false`. use std::convert::Infallible; use std::sync::Arc; use std::time::Duration; use askama::Template; use axum::extract::{Extension, Form, Path, Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Redirect}; use futures::stream::Stream; use rustwarden_core::middleware::csrf; use sea_orm::DatabaseConnection; use serde::Deserialize; use tower_sessions::Session; use uuid::Uuid; use rustwarden_core::auth::jwt::Claims; use rustwarden_core::error::AppError; use rustwarden_core::i18n::extractor::Locale; use rustwarden_core::i18n::service::I18nService; use rustwarden_fediverse::FediverseConfig; use rustwarden_fediverse::models::fediverse_pending_invite; use rustwarden_fediverse::models::remote_identity; use rustwarden_fediverse::services::fediverse_auth_service::{ ChallengeDecision, ChallengeStatus, FediverseAuthService, InitiateRequest, build_auth_decision_payload, }; use rustwarden_fediverse::services::fediverse_invite_service::FediverseInviteService; use rustwarden_fediverse::services::fediverse_outbound_service::{ EnqueueRequest, FediverseOutboundService, }; use rustwarden_fediverse::services::fediverse_trust_service::FediverseTrustService; use rustwarden_fediverse::models::fediverse_auth_challenge; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; /// Default challenge TTL for the AP-native auth flow (RFC 0006 §6). /// Operator override via `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` is honoured /// by the service layer separately; here we only provide a UI default /// to hand into [`FediverseAuthService::initiate`]. const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60); /// Whitelist of accepted `?reason=` values. Any other input falls back /// to [`ErrorReason::Unknown`] which renders a generic message — keeps /// the URL safe to share without leaking internal status names. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorReason { /// `Accept{AuthRequest}` not received within the challenge TTL. Timeout, /// IdP explicitly rejected the auth request. Rejected, /// Domain not in the allowlist (or in the blocklist). Untrusted, /// HTTP signature on the IdP response did not verify. InvalidSignature, /// Anything else (renders the generic catch-all message). Unknown, } impl ErrorReason { /// Parse the `?reason=` query value into an enum. Trims, lowercases. pub fn parse(raw: &str) -> Self { match raw.trim().to_ascii_lowercase().as_str() { "timeout" => Self::Timeout, "rejected" => Self::Rejected, "untrusted" => Self::Untrusted, "invalid_signature" | "invalid_sig" => Self::InvalidSignature, _ => Self::Unknown, } } /// i18n key suffix for the heading + body copy. pub fn i18n_key(self) -> &'static str { match self { Self::Timeout => "timeout", Self::Rejected => "rejected", Self::Untrusted => "untrusted", Self::InvalidSignature => "invalid_signature", Self::Unknown => "unknown", } } /// Stable wire form (echoed back into the template for icon styling). pub fn as_str(self) -> &'static str { self.i18n_key() } } /// `?reason=` query parameter for `GET /login/fediverse/error`. #[derive(Debug, Deserialize)] pub struct ErrorQuery { /// One of `timeout`, `rejected`, `untrusted`, `invalid_signature`. /// Anything else renders the generic message. pub reason: Option<String>, } /// Askama template for `GET /login/fediverse/error`. #[derive(Template)] #[template(path = "error_unauthorized.html")] #[allow(dead_code)] pub struct FediverseAuthErrorTemplate { /// IETF locale tag (`fr`, `en`, …) for `<html lang>`. pub current_locale: String, /// Browser tab title. pub page_title: String, /// Meta-description copy. pub subtitle: String, /// Wire form of the error reason — used by the template to pick an /// icon (timeout / invalid_signature / generic alert). pub reason: String, /// i18n-translated H1. pub heading: String, /// i18n-translated body paragraph. pub body: String, /// Primary CTA label. pub back_to_login: String, } rustwarden_core::impl_template_response!(FediverseAuthErrorTemplate); /// `GET /login/fediverse/error?reason=…` — landing page after a failed /// auth attempt. /// /// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so /// the page is invisible while the feature is off. pub async fn error( Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, State(db): State<DatabaseConnection>, Query(query): Query<ErrorQuery>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let reason = ErrorReason::parse(query.reason.as_deref().unwrap_or("")); let locale_str = locale.as_str(); let key = reason.i18n_key(); let heading_key = format!("fediverse.auth.error.{key}.title"); let body_key = format!("fediverse.auth.error.{key}.body"); let template = FediverseAuthErrorTemplate { current_locale: locale_str.to_string(), page_title: i18n_service .translate("fediverse.auth.error.page_title", locale_str) .await, subtitle: i18n_service .translate("fediverse.auth.error.subtitle", locale_str) .await, reason: reason.as_str().to_string(), heading: i18n_service.translate(&heading_key, locale_str).await, body: i18n_service.translate(&body_key, locale_str).await, back_to_login: i18n_service .translate("fediverse.auth.error.back_to_login", locale_str) .await, }; Ok(template.into_response()) } // ───────────────────────────────────────────────────────────────────── // /login/fediverse — login form + initiate (RFC 0023 Slice B iter. 2) // ───────────────────────────────────────────────────────────────────── /// Form payload for `POST /login/fediverse`. #[derive(Debug, Deserialize)] pub struct LoginInitiateForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// `@user@domain` handle the user typed. pub handle: String, } /// Askama template for `GET /login/fediverse`. #[derive(Template)] #[template(path = "login_form.html")] #[allow(dead_code, missing_docs)] pub struct FediverseLoginFormTemplate { pub current_locale: String, pub page_title: String, pub subtitle: String, pub csrf_token: String, pub heading: String, pub field_label: String, pub field_placeholder: String, pub field_help: String, pub submit_button: String, pub back_to_login: String, } rustwarden_core::impl_template_response!(FediverseLoginFormTemplate); /// `GET /login/fediverse` — render the AP-native login form. /// /// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`. pub async fn login_form( Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, State(db): State<DatabaseConnection>, session: Session, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let locale_str = locale.as_str(); let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let template = FediverseLoginFormTemplate { current_locale: locale_str.to_string(), page_title: i18n_service .translate("fediverse.auth.login.page_title", locale_str) .await, subtitle: i18n_service .translate("fediverse.auth.login.subtitle", locale_str) .await, csrf_token, heading: i18n_service .translate("fediverse.auth.login.heading", locale_str) .await, field_label: i18n_service .translate("fediverse.auth.login.field_label", locale_str) .await, field_placeholder: i18n_service .translate("fediverse.auth.login.field_placeholder", locale_str) .await, field_help: i18n_service .translate("fediverse.auth.login.field_help", locale_str) .await, submit_button: i18n_service .translate("fediverse.auth.login.submit", locale_str) .await, back_to_login: i18n_service .translate("fediverse.auth.login.back_to_login", locale_str) .await, }; Ok(template.into_response()) } /// Extract the `domain` part from a `@user@domain` (or `user@domain`) /// handle. Returns `None` on malformed input. Lowercased. pub fn extract_handle_domain(handle: &str) -> Option<String> { let trimmed = handle.trim().trim_start_matches('@'); let (_, domain) = trimmed.split_once('@')?; let domain = domain.trim(); if domain.is_empty() { return None; } Some(domain.to_ascii_lowercase()) } /// `POST /login/fediverse` — start an AP-native login. /// /// Pipeline: /// 1. Validate CSRF. /// 2. Reject if `fediverse_enabled = false` (404). /// 3. Parse handle ⇒ domain ; reject malformed via `error?reason=unknown`. /// 4. Check `FediverseTrustService::is_domain_allowed(domain)` ; /// redirect to `error?reason=untrusted` if denied. /// 5. Call `FediverseAuthService::initiate` ; on `Ok` redirect to /// `/login/fediverse/waiting/{id}` (page delivered iter. 3). /// 6. On any other error, redirect to `error?reason=unknown`. pub async fn login_initiate( State(db): State<DatabaseConnection>, session: Session, Form(form): Form<LoginInitiateForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let Some(domain) = extract_handle_domain(&form.handle) else { return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()); }; if !trust.is_domain_allowed(&domain).await? { return Ok(Redirect::to("/login/fediverse/error?reason=untrusted").into_response()); } let req = InitiateRequest { handle: form.handle.trim().to_string(), redirect_url: None, }; match FediverseAuthService::initiate(&db, req, CHALLENGE_TTL).await { Ok(challenge) => { Ok(Redirect::to(&format!("/login/fediverse/waiting/{}", challenge.id)).into_response()) } Err(AppError::Validation(_)) => { Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()) } Err(err) => { tracing::error!("FediverseAuthService::initiate failed: {err}"); Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()) } } } // ───────────────────────────────────────────────────────────────────── // /login/fediverse/waiting/{id} — page d'attente + SSE (iter. 3) // ───────────────────────────────────────────────────────────────────── /// Askama template for `GET /login/fediverse/waiting/{challenge_id}`. #[derive(Template)] #[template(path = "login_waiting.html")] #[allow(dead_code, missing_docs)] pub struct FediverseLoginWaitingTemplate { pub current_locale: String, pub page_title: String, pub subtitle: String, pub heading: String, pub explainer: String, pub cancel_button: String, /// Challenge UUID — embedded in the JS so the EventSource opens the /// correct SSE stream URL. pub challenge_id: String, } rustwarden_core::impl_template_response!(FediverseLoginWaitingTemplate); /// `GET /login/fediverse/waiting/{challenge_id}` — render the waiting /// page that opens an `EventSource` against the SSE endpoint below. pub async fn waiting( Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, State(db): State<DatabaseConnection>, Path(challenge_id): Path<Uuid>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } // Verify the challenge exists; otherwise show the error page rather // than a stale spinner. if FediverseAuthService::status(&db, challenge_id) .await .is_err() { return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()); } let locale_str = locale.as_str(); let template = FediverseLoginWaitingTemplate { current_locale: locale_str.to_string(), page_title: i18n_service .translate("fediverse.auth.waiting.page_title", locale_str) .await, subtitle: i18n_service .translate("fediverse.auth.waiting.subtitle", locale_str) .await, heading: i18n_service .translate("fediverse.auth.waiting.heading", locale_str) .await, explainer: i18n_service .translate("fediverse.auth.waiting.explainer", locale_str) .await, cancel_button: i18n_service .translate("fediverse.auth.waiting.cancel", locale_str) .await, challenge_id: challenge_id.to_string(), }; Ok(template.into_response()) } /// Polling cadence between two DB reads in [`sse_status`]. Two seconds /// is the master plan §10 default — small enough that the user /// perceives "live", large enough to keep the DB load negligible. const SSE_POLL_INTERVAL: Duration = Duration::from_secs(2); /// Maximum number of polls before the SSE stream gives up and closes. /// `5 min / 2 s = 150` matches the challenge TTL exactly so a never- /// answered challenge does not keep the connection alive forever. const SSE_MAX_POLLS: u32 = 150; /// `GET /login/fediverse/sse/{challenge_id}` — SSE polling endpoint. /// /// Emits one `event: status` per poll with payload /// `{"state":"pending|approved|rejected|expired|consumed"}`. Stops after /// the first terminal status or `SSE_MAX_POLLS` iterations, whichever /// comes first. The endpoint is **public** — the challenge UUID is /// itself the unguessable bearer (256 bits of entropy, RFC 0006). pub async fn sse_status( State(db): State<DatabaseConnection>, Path(challenge_id): Path<Uuid>, ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { let stream = async_status_stream(db, challenge_id); Sse::new(stream).keep_alive(KeepAlive::default()) } fn async_status_stream( db: DatabaseConnection, challenge_id: Uuid, ) -> impl Stream<Item = Result<Event, Infallible>> { use futures::stream; stream::unfold( StreamState { db, challenge_id, polls: 0, done: false, }, |mut s| async move { if s.done || s.polls >= SSE_MAX_POLLS { return None; } if s.polls > 0 { tokio::time::sleep(SSE_POLL_INTERVAL).await; } s.polls += 1; let state_str = match FediverseAuthService::status(&s.db, s.challenge_id).await { Ok(model) => ChallengeStatus::parse(&model.status).as_str(), Err(_) => "expired", }; let event = Event::default() .event("status") .data(format!(r#"{{"state":"{state_str}"}}"#)); // Mark stream as terminal when status is non-pending so the // next iteration short-circuits. if state_str != "pending" { s.done = true; } Some((Ok::<_, Infallible>(event), s)) }, ) } struct StreamState { db: DatabaseConnection, challenge_id: Uuid, polls: u32, done: bool, } // ───────────────────────────────────────────────────────────────────── // /fediverse/consent/{id} — IdP-side consent screen (iter. 4) // ───────────────────────────────────────────────────────────────────── /// Form payload for `POST /fediverse/consent/{id}`. The hidden field /// `decision` is `"approve"` or `"reject"` ; anything else is treated /// as a rejection (defensive default). #[derive(Debug, Deserialize)] pub struct ConsentForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// `"approve"` or `"reject"`. pub decision: String, } /// Askama template for `GET /fediverse/consent/{id}`. #[derive(Template)] #[template(path = "consent_screen.html")] #[allow(dead_code, missing_docs)] pub struct FediverseConsentTemplate { pub current_locale: String, pub page_title: String, pub csrf_token: String, pub heading: String, pub explainer: String, /// Domain of the requesting instance (`b.tld`). pub requester_domain: String, /// Handle the request claims to authenticate (`@alice@a.tld`). pub handle: String, pub field_label_requester: String, pub field_label_handle: String, pub approve_button: String, pub reject_button: String, /// `true` once the challenge has reached a terminal state — flips /// the action area to a status banner instead of the form. pub already_decided: bool, pub already_decided_text: String, /// Challenge UUID — embedded in the form action URL. pub challenge_id: String, } rustwarden_core::impl_template_response!(FediverseConsentTemplate); /// `GET /fediverse/consent/{challenge_id}` — render the consent screen /// for a logged-in local user. /// /// The current implementation **renders** the screen but only fully /// supports the *Reject* path. *Approve* requires the inbound auth /// challenge to carry a `requested_remote_id`, which the inbox handler /// will set in a follow-up to RFC 0006 (the existing inbox `auth_inbox` /// in `rustwarden_fediverse::auth_routes` documents this TODO at the /// service-layer level). When `requested_remote_id` is absent the POST /// handler refuses Approve with a clear i18n error. pub async fn consent_form( Extension(_claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, State(db): State<DatabaseConnection>, session: Session, Path(challenge_id): Path<Uuid>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let model = match FediverseAuthService::status(&db, challenge_id).await { Ok(m) => m, Err(_) => return Ok(axum::http::StatusCode::NOT_FOUND.into_response()), }; let already_decided = ChallengeStatus::parse(&model.status) != ChallengeStatus::Pending; let locale_str = locale.as_str(); let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let already_decided_text = if already_decided { i18n_service .translate("fediverse.auth.consent.already_decided", locale_str) .await } else { String::new() }; let template = FediverseConsentTemplate { current_locale: locale_str.to_string(), page_title: i18n_service .translate("fediverse.auth.consent.page_title", locale_str) .await, csrf_token, heading: i18n_service .translate("fediverse.auth.consent.heading", locale_str) .await, explainer: i18n_service .translate("fediverse.auth.consent.explainer", locale_str) .await, requester_domain: model.domain, handle: model.handle, field_label_requester: i18n_service .translate("fediverse.auth.consent.label_requester", locale_str) .await, field_label_handle: i18n_service .translate("fediverse.auth.consent.label_handle", locale_str) .await, approve_button: i18n_service .translate("fediverse.auth.consent.approve", locale_str) .await, reject_button: i18n_service .translate("fediverse.auth.consent.reject", locale_str) .await, already_decided, already_decided_text, challenge_id: challenge_id.to_string(), }; Ok(template.into_response()) } /// `POST /fediverse/consent/{challenge_id}` — record the user's /// decision and redirect to `/home`. /// /// *Reject* always succeeds. *Approve* requires the challenge row to /// have a `requested_remote_id` (set by the inbox handler when the /// `AuthRequest` arrives). Without it we surface a Validation error /// rather than mint an unbound approval, which would let an attacker /// inject any `remote_identity_id` from the form. The follow-up RFC /// 0006 Phase 2 will close the binding. pub async fn consent_submit( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, Path(challenge_id): Path<Uuid>, Form(form): Form<ConsentForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } // Pre-fetch challenge: we need `nonce` + `requested_remote_id` both // to feed `record_decision` and to enqueue the outbound back-channel // (RFC 0025). Doing this once avoids a second roundtrip on approve. let challenge = FediverseAuthService::status(&db, challenge_id).await?; let remote_id = challenge.requested_remote_id.ok_or_else(|| { AppError::Validation( "auth challenge has no remote_identity_id binding — inbox \ enrichment (RFC 0006 Phase 2) not yet wired" .to_string(), ) })?; let approve_flag = form.decision.as_str() == "approve"; let decision = if approve_flag { ChallengeDecision::Approve { remote_identity_id: remote_id, } } else { ChallengeDecision::Reject }; FediverseAuthService::record_decision(&db, challenge_id, decision).await?; // RFC 0025 — push the decision to B's inbox as Accept(AuthRequest) // / Reject(AuthRequest). Failure to enqueue must not roll back the // local decision (the row already moved past Pending), but we // surface it so the operator sees the dead-letter / retry need. if let Err(e) = enqueue_auth_decision(&db, &claims, &challenge, approve_flag).await { tracing::error!( challenge_id = %challenge_id, decision = if approve_flag { "approve" } else { "reject" }, error = %e, "consent_submit: failed to enqueue back-channel activity", ); } Ok(Redirect::to("/home").into_response()) } /// Build and enqueue the `Accept(AuthRequest)` or `Reject(AuthRequest)` /// activity (RFC 0025) addressed to the requester's inbox. /// /// Resolves the requester via `challenge.requested_remote_id`. The /// approver actor URL is derived from the IdP's `FediverseConfig` base /// + the approver's username (in `Claims`). async fn enqueue_auth_decision( db: &DatabaseConnection, approver: &Claims, challenge: &fediverse_auth_challenge::Model, approve: bool, ) -> Result<(), AppError> { let remote_id = challenge.requested_remote_id.ok_or_else(|| { AppError::Internal("challenge approved without requested_remote_id".into()) })?; let requester = remote_identity::Entity::find_by_id(remote_id) .one(db) .await? .ok_or_else(|| { AppError::Internal(format!( "remote_identity {remote_id} disappeared between checks" )) })?; let config = FediverseConfig::from_env() .map_err(|e| AppError::Config(format!("FediverseConfig::from_env: {e}")))?; let approver_actor_url = format!("{}users/{}", config.base_url(), approver.username); let payload = build_auth_decision_payload( config.base_url(), &approver_actor_url, &requester.inbox_url, &challenge.nonce, approve, ); let activity_id = payload["id"].as_str().unwrap_or("").to_string(); FediverseOutboundService::enqueue( db, EnqueueRequest { activity_id, payload_json: serde_json::to_string(&payload) .map_err(|e| AppError::Internal(format!("payload serialize: {e}")))?, target_inbox: requester.inbox_url.clone(), target_domain: requester.domain.clone(), actor_user_id: Some(approver.sub), }, ) .await?; Ok(()) } // ───────────────────────────────────────────────────────────────────── // /profile/connected_apps (RFC 0024 Slice C iter. 5) // ───────────────────────────────────────────────────────────────────── /// One row rendered into the connected-apps table — a remote instance /// where the current user has an active federated session (= a /// challenge in `approved` or `consumed` status). #[derive(Debug, Clone)] pub struct ConnectedAppRowCtx { /// Domain of the requesting instance (`b.tld`). pub domain: String, /// Number of distinct sessions / approvals for that domain. pub session_count: u64, /// Most recent `created_at` for this domain, formatted /// `YYYY-MM-DD HH:MM`. pub last_seen: String, } /// Askama template for `GET /profile/connected_apps`. #[derive(Template)] #[template(path = "connected_apps.html")] #[allow(dead_code, missing_docs)] pub struct ConnectedAppsTemplate { pub current_locale: String, pub page_title: String, pub heading: String, pub subtitle: String, pub rows: Vec<ConnectedAppRowCtx>, pub has_rows: bool, pub table_domain: String, pub table_count: String, pub table_last_seen: String, pub empty_title: String, pub empty_body: String, pub revoke_note: String, pub back_to_profile: String, } rustwarden_core::impl_template_response!(ConnectedAppsTemplate); /// `GET /profile/connected_apps` — section profil affichant la liste /// des instances fédérées où l'utilisateur a actuellement une session. /// /// Mode dégradé silencieux (Plan UI §3 P9) : `404` quand /// `fediverse_enabled = false`. Lecture seule en iter. 5 ; le bouton /// « Révoquer » est différé à RFC 0006 Phase 2 (qui ajoutera la méthode /// `FediverseAuthService::revoke_remote_session(challenge_id)` capable /// de blacklister le JWT remote correspondant). pub async fn connected_apps( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, State(db): State<DatabaseConnection>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } // Build the local handle of the current user. let domain_self = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "localhost".to_string()); let local_handle = format!("@{}@{}", claims.username, domain_self); // Pull all `approved` / `consumed` challenges for that handle. let challenges = fediverse_auth_challenge::Entity::find() .filter(fediverse_auth_challenge::Column::Handle.eq(local_handle)) .filter(fediverse_auth_challenge::Column::Status.is_in(["approved", "consumed"])) .order_by_desc(fediverse_auth_challenge::Column::CreatedAt) .all(&db) .await?; // Group by domain: count + most recent created_at. use std::collections::BTreeMap; let mut grouped: BTreeMap<String, (u64, chrono::DateTime<chrono::Utc>)> = BTreeMap::new(); for c in challenges { let entry = grouped.entry(c.domain.clone()).or_insert((0, c.created_at)); entry.0 += 1; if c.created_at > entry.1 { entry.1 = c.created_at; } } let mut rows: Vec<ConnectedAppRowCtx> = grouped .into_iter() .map(|(domain, (count, last))| ConnectedAppRowCtx { domain, session_count: count, last_seen: last.format("%Y-%m-%d %H:%M").to_string(), }) .collect(); // Most recently active first. rows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); let has_rows = !rows.is_empty(); let locale_str = locale.as_str(); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = ConnectedAppsTemplate { current_locale: locale_str.to_string(), page_title: t("fediverse_connected_apps.page_title").await, heading: t("fediverse_connected_apps.heading").await, subtitle: t("fediverse_connected_apps.subtitle").await, rows, has_rows, table_domain: t("fediverse_connected_apps.table_domain").await, table_count: t("fediverse_connected_apps.table_count").await, table_last_seen: t("fediverse_connected_apps.table_last_seen").await, empty_title: t("fediverse_connected_apps.empty_title").await, empty_body: t("fediverse_connected_apps.empty_body").await, revoke_note: t("fediverse_connected_apps.revoke_note").await, back_to_profile: t("fediverse_connected_apps.back_to_profile").await, }; Ok(template.into_response()) } // ───────────────────────────────────────────────────────────────────── // /fediverse/invite/{id} (RFC 0024 Slice C iter. 6) // ───────────────────────────────────────────────────────────────────── /// Form payload for `POST /fediverse/invite/{id}`. #[derive(Debug, Deserialize)] pub struct InviteLandingForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// `"accept"` or `"reject"`. pub decision: String, } /// Askama template for `GET /fediverse/invite/{id}`. #[derive(Template)] #[template(path = "invite_landing.html")] #[allow(dead_code, missing_docs)] pub struct InviteLandingTemplate { pub current_locale: String, pub page_title: String, pub csrf_token: String, pub heading: String, pub explainer: String, pub team_label: String, pub team_name: String, pub role_label: String, pub role_value: String, pub status_label: String, pub status_value: String, pub accept_button: String, pub reject_button: String, /// `true` when the invite is in a terminal state (Accepted / Rejected /// / Revoked / Expired) — flips the UI to a status banner. pub already_decided: bool, pub already_decided_text: String, /// Challenge UUID for form action. pub invite_id: String, /// `true` when the current user can accept (= remote identity bound). pub can_accept: bool, pub cannot_accept_note: String, } rustwarden_core::impl_template_response!(InviteLandingTemplate); /// `GET /fediverse/invite/{invite_id}` — landing page for a federated /// team invitation. /// /// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` or /// the invite does not exist. The current user (`Claims`) must be a /// **Remote** identity to be able to accept — local users only see /// the page in read-only mode (the form refuses to call `accept` /// because `record_decision` requires `remote_identity_id`). pub async fn invite_landing_form( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, State(db): State<DatabaseConnection>, session: Session, axum::extract::Path(invite_id): axum::extract::Path<Uuid>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let Some(invite) = fediverse_pending_invite::Entity::find_by_id(invite_id) .one(&db) .await? else { return Ok((axum::http::StatusCode::NOT_FOUND, "Invite not found").into_response()); }; // Load team name for display. let team_name = rustwarden_core::models::team::Entity::find_by_id(invite.team_id) .one(&db) .await? .map(|t| t.name) .unwrap_or_else(|| invite.team_id.to_string()); // Status terminal? use rustwarden_fediverse::services::fediverse_invite_service::InviteStatus; let status_parsed = InviteStatus::parse(&invite.status); let already_decided = status_parsed != InviteStatus::Pending; // Can accept = current user has a remote_identity_id (RFC 0007). let can_accept = claims.is_remote() && claims.remote_identity_id().is_some(); let locale_str = locale.as_str(); let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = InviteLandingTemplate { current_locale: locale_str.to_string(), page_title: t("fediverse_invite_landing.page_title").await, csrf_token, heading: t("fediverse_invite_landing.heading").await, explainer: t("fediverse_invite_landing.explainer").await, team_label: t("fediverse_invite_landing.team_label").await, team_name, role_label: t("fediverse_invite_landing.role_label").await, role_value: invite.role.clone(), status_label: t("fediverse_invite_landing.status_label").await, status_value: invite.status.clone(), accept_button: t("fediverse_invite_landing.accept_button").await, reject_button: t("fediverse_invite_landing.reject_button").await, already_decided, already_decided_text: if already_decided { t("fediverse_invite_landing.already_decided").await } else { String::new() }, invite_id: invite_id.to_string(), can_accept, cannot_accept_note: if can_accept { String::new() } else { t("fediverse_invite_landing.cannot_accept_note").await }, }; Ok(template.into_response()) } /// `POST /fediverse/invite/{invite_id}` — record the invitee's decision. /// /// *Reject* always succeeds. *Accept* requires the current user to be a /// **Remote** identity carrying a `remote_identity_id` claim — the /// service inserts the `team_members` row with that FK. pub async fn invite_landing_submit( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, axum::extract::Path(invite_id): axum::extract::Path<Uuid>, Form(form): Form<InviteLandingForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } match form.decision.as_str() { "accept" => { let remote_id = claims.remote_identity_id().ok_or_else(|| { AppError::Validation( "Only remote identities can accept federated invites; \ local users must reject or ignore." .into(), ) })?; FediverseInviteService::accept(&db, invite_id, remote_id).await?; } // Anything other than "accept" is a reject (defensive default). _ => { FediverseInviteService::reject(&db, invite_id).await?; } } Ok(Redirect::to("/home").into_response()) } #[cfg(test)] mod tests { use super::*; #[test] fn error_reason_parses_known_values() { assert_eq!(ErrorReason::parse("timeout"), ErrorReason::Timeout); assert_eq!(ErrorReason::parse("rejected"), ErrorReason::Rejected); assert_eq!(ErrorReason::parse("untrusted"), ErrorReason::Untrusted); assert_eq!( ErrorReason::parse("invalid_signature"), ErrorReason::InvalidSignature ); assert_eq!( ErrorReason::parse("invalid_sig"), ErrorReason::InvalidSignature ); } #[test] fn error_reason_falls_back_to_unknown() { assert_eq!(ErrorReason::parse(""), ErrorReason::Unknown); assert_eq!(ErrorReason::parse("garbage"), ErrorReason::Unknown); assert_eq!(ErrorReason::parse("500"), ErrorReason::Unknown); } #[test] fn error_reason_trims_and_lowercases() { assert_eq!(ErrorReason::parse(" TIMEOUT "), ErrorReason::Timeout); assert_eq!( ErrorReason::parse("Invalid_Signature"), ErrorReason::InvalidSignature ); } #[test] fn extract_handle_domain_accepts_canonical() { assert_eq!( extract_handle_domain("@alice@example.org"), Some("example.org".to_string()) ); assert_eq!( extract_handle_domain("alice@example.org"), Some("example.org".to_string()) ); assert_eq!( extract_handle_domain(" @bob@MASTODON.SOCIAL "), Some("mastodon.social".to_string()) ); } #[test] fn extract_handle_domain_rejects_malformed() { assert_eq!(extract_handle_domain(""), None); assert_eq!(extract_handle_domain("alice"), None); assert_eq!(extract_handle_domain("alice@"), None); assert_eq!(extract_handle_domain("@@example.org"), None); } #[test] fn error_reason_round_trips_through_str() { for r in [ ErrorReason::Timeout, ErrorReason::Rejected, ErrorReason::Untrusted, ErrorReason::InvalidSignature, ErrorReason::Unknown, ] { assert_eq!(ErrorReason::parse(r.as_str()), r); } } }
GitRust