//! Magic-link routes — turn an API-key authenticated desktop call into a //! browser session without an interactive password login. //! //! Flow: //! 1. Desktop client → `POST /api/auth/magic-link` (X-API-Key) with the //! desired `return_to` path. Server stores a one-time token (TTL 60s) //! and returns it. //! 2. Desktop client opens the system browser at //! `{server_url}/web/magic?token=…`. //! 3. The browser hits `GET /web/magic`. The server consumes the token //! (one-time-use), installs a regular `WebSession`, sets the session //! cookie, and 303-redirects to `return_to`. use std::sync::Arc; use std::time::{Duration, Instant}; use axum::extract::{Query, State}; use axum::http::header; use axum::response::{IntoResponse, Redirect, Response}; use axum::{Json, http::StatusCode}; use axum_extra::extract::cookie::CookieJar; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::auth::AuthenticatedUser; use crate::config::Config; use crate::error::AppError; use crate::magic_link::{MagicLinkStore, PendingMagicLink}; use crate::web_session::{SessionStore, WebSession, build_session_cookie, generate_token}; /// Magic-link tokens only need to survive click → browser-launch → first /// request. 60s gives slow systems headroom without lengthening the attack /// window unnecessarily. const MAGIC_LINK_TTL_SECS: u64 = 60; /// Default landing page when the desktop client does not specify one. const DEFAULT_RETURN_TO: &str = "/web/cases"; #[derive(Deserialize, Default)] pub struct MagicLinkRequest { #[serde(default)] pub return_to: Option, } #[derive(Serialize)] pub struct MagicLinkResponse { pub token: String, } /// Issue a one-time magic-link token. Authenticated by `X-API-Key` — /// re-uses the existing `AuthenticatedUser` extractor so policy stays /// in one place. pub async fn handle_create( user: AuthenticatedUser, State(store): State, body: Option>, ) -> Result, AppError> { let req = body.map(|Json(r)| r).unwrap_or_default(); let return_to = req .return_to .unwrap_or_else(|| DEFAULT_RETURN_TO.to_owned()); // Open-redirect guard: only accept paths under our own /web/ tree. // Reject schemes, hosts, protocol-relative URLs, and traversal. if !is_safe_return_to(&return_to) { warn!(slug = %user.slug, return_to = %return_to, "magic-link rejected: unsafe return_to"); return Err(AppError::BadRequest( "return_to must be a path under /web/".into(), )); } let token = generate_token(); let pending = PendingMagicLink { slug: user.slug.clone(), role: user.role.clone(), return_to, expires_at: Instant::now() + Duration::from_secs(MAGIC_LINK_TTL_SECS), }; { let mut w = store.write().await; // Best-effort eviction so the map cannot grow unbounded under load. w.retain(|_, v| v.expires_at > Instant::now()); w.insert(token.clone(), pending); } info!(slug = %user.slug, "magic-link issued"); Ok(Json(MagicLinkResponse { token })) } #[derive(Deserialize)] pub struct ConsumeQuery { pub token: String, } /// Consume a magic-link token: install a session cookie and redirect to /// the previously-stored `return_to`. One-time use — the token is removed /// from the store on first lookup, success or expiry alike. pub async fn handle_consume( State(config): State>, State(magic_store): State, State(session_store): State, jar: CookieJar, Query(q): Query, ) -> Response { let pending = { let mut w = magic_store.write().await; w.remove(&q.token) }; let Some(pending) = pending else { warn!("magic-link consume failed: unknown or already-used token"); return redirect_to_login(); }; if pending.expires_at <= Instant::now() { warn!(slug = %pending.slug, "magic-link consume failed: expired"); return redirect_to_login(); } let session_token = generate_token(); let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600); { let mut w = session_store.write().await; w.insert( session_token.clone(), WebSession { slug: pending.slug.clone(), role: pending.role.clone(), expires_at: Instant::now() + ttl, }, ); } info!(slug = %pending.slug, "magic-link consumed → session installed"); let cookie = build_session_cookie(session_token, ttl, config.cookie_secure); let jar = jar.add(cookie); // 303 See Other forces the browser to follow with GET. Combined with // `Referrer-Policy: no-referrer`, the token does not leak to any // resource the redirect target may load. let mut response = (jar, Redirect::to(&pending.return_to)).into_response(); response.headers_mut().insert( header::REFERRER_POLICY, "no-referrer".parse().expect("static header value"), ); response } fn redirect_to_login() -> Response { Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/web/login") .header(header::REFERRER_POLICY, "no-referrer") .body(axum::body::Body::empty()) .expect("static redirect response") } /// Allow only same-origin paths under `/web/`. Rejects schemes, host /// authorities, protocol-relative URLs (`//evil.com/...`), and the /// trivially malformed empty string. fn is_safe_return_to(path: &str) -> bool { if path.is_empty() { return false; } if path.starts_with("//") { return false; // protocol-relative — would jump host } if !path.starts_with("/web/") { return false; } // Defensive: a `\` anywhere can confuse some legacy parsers into // treating the rest as an authority. Reject outright. if path.contains('\\') { return false; } true } #[cfg(test)] mod tests { use super::*; #[test] fn safe_return_to_accepts_web_paths() { assert!(is_safe_return_to("/web/cases")); assert!(is_safe_return_to( "/web/cases/123e4567-e89b-12d3-a456-426614174000" )); } #[test] fn safe_return_to_rejects_open_redirects() { assert!(!is_safe_return_to("")); assert!(!is_safe_return_to("/")); assert!(!is_safe_return_to("//evil.com/path")); assert!(!is_safe_return_to("https://evil.com/")); assert!(!is_safe_return_to("/api/upload")); assert!(!is_safe_return_to("/web\\evil")); } }