From 76e8ee18e9ea558ab60626cc1fb088c107342d7a Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 19 Apr 2026 16:00:12 +0200 Subject: [PATCH] feat: Implement magic link authentication This commit introduces a new magic link authentication flow. The desktop client can now request a temporary, one-time-use token from the server. This token is then used to open a URL in the system browser, which redirects to the server. The server consumes the token, installs a regular web session, and redirects the user --- CLAUDE.md | 5 +- client-desktop/src/app.rs | 35 ++++- client-desktop/src/magic_link.rs | 120 +++++++++++++++ client-desktop/src/main.rs | 1 + server/src/auth.rs | 4 +- server/src/lib.rs | 10 ++ server/src/magic_link.rs | 27 ++++ server/src/main.rs | 1 + server/src/routes/login.rs | 34 ++--- server/src/routes/magic.rs | 202 +++++++++++++++++++++++++ server/src/routes/mod.rs | 3 + server/src/web_session.rs | 35 ++++- server/tests/magic_link_test.rs | 252 +++++++++++++++++++++++++++++++ 13 files changed, 690 insertions(+), 39 deletions(-) create mode 100644 client-desktop/src/magic_link.rs create mode 100644 server/src/magic_link.rs create mode 100644 server/src/routes/magic.rs create mode 100644 server/tests/magic_link_test.rs diff --git a/CLAUDE.md b/CLAUDE.md index 7b85738..28076cf 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,6 @@ * CRITICAL: Im Code und den Kommentaren muss alles auf ENGLISCH sein. * CRITICAL: Bevor du Code änderst, erkläre mir, warum du das machst und welche Konzepte du nutzt. Ich will was dazulernen. WARTE AUF MEINE FREIGABE, BEVOR DU CODE ÄNDERST. -* CRITICAL: "cargo clippy" hat immer Recht! - * NOCHMAL: WARTE AUF MEIN OK, BEVOR DU CODE ÄNDERST ODER ERZEUGST! Führe alles schrittweise aus. Erkläre deine Schritte. Wenn es ein Problem gibt, dann FRAGE MICH. Beantworte deine Fragen nicht selber. ## Rust @@ -17,6 +15,9 @@ * Ich bin sehr erfahren mit Delphi und verstehe auch funktionale Sprachen. Rust ist neu für mich. Erkläre alles, was Rust von Delphi unterscheidet. * Performance: Bevorzuge `Rc` + lokales `downcast_ref` gegenüber Deep Copies via `Rc::new(obj.clone())`. * Vermeide lange Namespace-Ketten im Code (z.B. crate::ast::compiler::...). Nutze use Statements. +* Mach zu gegebener Zeit ein "cargo fmt" + +* CRITICAL: "cargo clippy" hat immer Recht! ## Bugs diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index e6789ab..8cc75c7 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -283,14 +283,33 @@ impl DoctateApp { let Some(cfg) = &self.config else { return; }; - let url = format!( - "{}/web/cases/{}", - cfg.server_url.trim_end_matches('/'), - case_id - ); - if let Err(e) = webbrowser::open(&url) { - warn!(url = %url, error = %e, "open browser failed"); - } + let server_url = cfg.server_url.trim_end_matches('/').to_owned(); + let api_key = cfg.api_key.clone(); + let http = self.http_client.clone(); + let return_to = format!("/web/cases/{case_id}"); + let fallback_url = format!("{server_url}{return_to}"); + + // Fire-and-forget on the runtime. Blocking the egui UI thread on + // an HTTP round-trip would freeze the window for ~100–500ms. + self.runtime.spawn(async move { + let url = match crate::magic_link::build_magic_url( + &http, + &server_url, + &api_key, + &return_to, + ) + .await + { + Ok(u) => u, + Err(e) => { + warn!(error = %e, "magic-link request failed; falling back to plain URL"); + fallback_url + } + }; + if let Err(e) = webbrowser::open(&url) { + warn!(url = %url, error = %e, "open browser failed"); + } + }); } fn drain_recorder_events(&mut self) { diff --git a/client-desktop/src/magic_link.rs b/client-desktop/src/magic_link.rs new file mode 100644 index 0000000..4a33cbf --- /dev/null +++ b/client-desktop/src/magic_link.rs @@ -0,0 +1,120 @@ +//! Client-side helper: ask the server for a one-time magic-link token and +//! build the URL the system browser should open. +//! +//! Kept separate from `app.rs` so the network call can be unit-tested +//! against a wiremock server without involving `webbrowser::open` (which +//! would launch a real browser during tests). +//! +//! # Behaviour on error +//! +//! Caller decides. The desktop app's `on_open_web` falls back to the +//! plain case URL if this fails — the doctor lands on the login page and +//! signs in manually rather than seeing a dead button. + +use doctate_common::API_KEY_HEADER; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Serialize)] +struct CreateRequest<'a> { + return_to: &'a str, +} + +#[derive(Deserialize)] +struct CreateResponse { + token: String, +} + +#[derive(Debug, Error)] +pub enum MagicLinkError { + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("server returned status {0}")] + Status(reqwest::StatusCode), +} + +/// Request a magic-link token and build the full URL to open. The +/// `return_to` path is what the browser lands on **after** the token +/// is consumed; must start with `/web/` (server enforces this too). +pub async fn build_magic_url( + http: &reqwest::Client, + server_url: &str, + api_key: &str, + return_to: &str, +) -> Result { + let base = server_url.trim_end_matches('/'); + let resp = http + .post(format!("{base}/api/auth/magic-link")) + .header(API_KEY_HEADER, api_key) + .json(&CreateRequest { return_to }) + .send() + .await?; + + if !resp.status().is_success() { + return Err(MagicLinkError::Status(resp.status())); + } + + let parsed: CreateResponse = resp.json().await?; + Ok(format!("{base}/web/magic?token={}", parsed.token)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn returns_full_url_with_token() { + let server = MockServer::start().await; + let token = "tok-1234567890"; + + Mock::given(method("POST")) + .and(path("/api/auth/magic-link")) + .and(header(API_KEY_HEADER, "test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": token }))) + .mount(&server) + .await; + + let http = reqwest::Client::new(); + let url = build_magic_url(&http, &server.uri(), "test-key", "/web/cases/abc") + .await + .expect("magic url"); + assert_eq!(url, format!("{}/web/magic?token={token}", server.uri())); + } + + #[tokio::test] + async fn propagates_server_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth/magic-link")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let http = reqwest::Client::new(); + let err = build_magic_url(&http, &server.uri(), "wrong-key", "/web/cases/abc") + .await + .expect_err("should error"); + assert!(matches!(err, MagicLinkError::Status(s) if s.as_u16() == 401)); + } + + #[tokio::test] + async fn trims_trailing_slash_in_server_url() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth/magic-link")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": "abc" }))) + .mount(&server) + .await; + + let http = reqwest::Client::new(); + let url_with_slash = format!("{}/", server.uri()); + let url = build_magic_url(&http, &url_with_slash, "k", "/web/cases/abc") + .await + .expect("magic url"); + assert!(!url.contains("//web/"), "double slash leaked: {url}"); + } +} + diff --git a/client-desktop/src/main.rs b/client-desktop/src/main.rs index 6c2408e..d75aa86 100644 --- a/client-desktop/src/main.rs +++ b/client-desktop/src/main.rs @@ -1,5 +1,6 @@ mod app; mod config; +mod magic_link; mod paths; mod recorder; mod state; diff --git a/server/src/auth.rs b/server/src/auth.rs index 147edb7..1ffb777 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -10,15 +10,13 @@ use tracing::warn; use crate::config::Config; use crate::error::AppError; -use crate::web_session::SessionStore; +use crate::web_session::{SESSION_COOKIE, SessionStore}; /// Truncate a session token to a non-sensitive prefix for logs. fn token_prefix(token: &str) -> String { token.chars().take(8).collect() } -const SESSION_COOKIE: &str = "session"; - /// Extracted from the X-API-Key header on every authenticated request. pub struct AuthenticatedUser { pub slug: String, diff --git a/server/src/lib.rs b/server/src/lib.rs index bda3b32..88ebf18 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -4,6 +4,7 @@ pub mod case_id; pub mod config; pub mod error; pub mod gazetteer; +pub mod magic_link; pub mod models; pub mod paths; pub mod routes; @@ -18,6 +19,7 @@ use axum::extract::FromRef; use analyze::AnalyzeSender; use config::Config; +use magic_link::MagicLinkStore; use transcribe::TranscribeSender; use web_session::SessionStore; @@ -73,6 +75,7 @@ pub struct AppState { pub transcribe_tx: TranscribeSender, pub analyze_tx: AnalyzeSender, pub session_store: SessionStore, + pub magic_link_store: MagicLinkStore, pub analyze_busy: AnalyzeBusy, pub transcribe_busy: TranscribeBusy, } @@ -101,6 +104,12 @@ impl FromRef for SessionStore { } } +impl FromRef for MagicLinkStore { + fn from_ref(state: &AppState) -> Self { + state.magic_link_store.clone() + } +} + impl FromRef for AnalyzeBusy { fn from_ref(state: &AppState) -> Self { state.analyze_busy.clone() @@ -135,6 +144,7 @@ pub fn create_router(config: Arc) -> Router { transcribe_tx, analyze_tx, session_store: web_session::new_store(), + magic_link_store: magic_link::new_store(), analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), }) diff --git a/server/src/magic_link.rs b/server/src/magic_link.rs new file mode 100644 index 0000000..192dbdf --- /dev/null +++ b/server/src/magic_link.rs @@ -0,0 +1,27 @@ +//! Short-lived, one-time tokens that turn a desktop API-key auth into a +//! browser session without an interactive password login. +//! +//! Lifecycle: the desktop client calls `POST /api/auth/magic-link` (API-key +//! authenticated), gets a token, opens the browser at `/web/magic?token=…`. +//! The web handler removes the token (one-time-use) and installs a regular +//! `WebSession`. TTL is intentionally short — the token only needs to +//! survive the click → browser-launch → first-request round trip. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use tokio::sync::RwLock; + +pub struct PendingMagicLink { + pub slug: String, + pub role: String, + pub return_to: String, + pub expires_at: Instant, +} + +pub type MagicLinkStore = Arc>>; + +pub fn new_store() -> MagicLinkStore { + Arc::new(RwLock::new(HashMap::new())) +} diff --git a/server/src/main.rs b/server/src/main.rs index 611e8cd..863c5ac 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -160,6 +160,7 @@ async fn main() { transcribe_tx, analyze_tx, session_store: doctate_server::web_session::new_store(), + magic_link_store: doctate_server::magic_link::new_store(), analyze_busy: doctate_server::AnalyzeBusy(analyze_busy), transcribe_busy: doctate_server::TranscribeBusy(transcribe_busy), }; diff --git a/server/src/routes/login.rs b/server/src/routes/login.rs index 3b778cf..9e82b36 100644 --- a/server/src/routes/login.rs +++ b/server/src/routes/login.rs @@ -5,18 +5,15 @@ use askama::Template; use axum::Form; use axum::extract::State; use axum::response::{Html, IntoResponse, Redirect, Response}; -use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; -use rand::Rng; -use rand::distributions::Alphanumeric; +use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; use tracing::{info, warn}; use crate::config::Config; use crate::error::AppError; -use crate::web_session::{SessionStore, WebSession}; - -const COOKIE_NAME: &str = "session"; -const TOKEN_LEN: usize = 43; +use crate::web_session::{ + SESSION_COOKIE, SessionStore, WebSession, build_session_cookie, generate_token, +}; #[derive(Template)] #[template(path = "login.html")] @@ -59,11 +56,7 @@ pub async fn handle_login_submit( } let user = user.unwrap(); - let token: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(TOKEN_LEN) - .map(char::from) - .collect(); + let token = generate_token(); let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600); { @@ -80,7 +73,7 @@ pub async fn handle_login_submit( info!(slug = %user.slug, "login ok"); - let cookie = build_cookie(token, ttl, config.cookie_secure); + let cookie = build_session_cookie(token, ttl, config.cookie_secure); let jar = jar.add(cookie); Ok((jar, Redirect::to("/web/cases")).into_response()) } @@ -90,7 +83,7 @@ pub async fn handle_logout( State(store): State, jar: CookieJar, ) -> Result { - if let Some(c) = jar.get(COOKIE_NAME) { + if let Some(c) = jar.get(SESSION_COOKIE) { let token = c.value().to_owned(); let mut w = store.write().await; let removed = w.remove(&token); @@ -100,7 +93,8 @@ pub async fn handle_logout( } // Expire the cookie by setting an empty value with max_age=0. - let mut cookie = build_cookie(String::new(), Duration::from_secs(0), config.cookie_secure); + let mut cookie = + build_session_cookie(String::new(), Duration::from_secs(0), config.cookie_secure); cookie.make_removal(); let jar = jar.add(cookie); Ok((jar, Redirect::to("/web/login")).into_response()) @@ -112,13 +106,3 @@ fn render_login(error: Option<&'static str>) -> Result, AppError> { .map(Html) .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) } - -fn build_cookie(value: String, max_age: Duration, secure: bool) -> Cookie<'static> { - Cookie::build((COOKIE_NAME, value)) - .http_only(true) - .secure(secure) - .same_site(SameSite::Strict) - .path("/") - .max_age(time::Duration::seconds(max_age.as_secs() as i64)) - .build() -} diff --git a/server/src/routes/magic.rs b/server/src/routes/magic.rs new file mode 100644 index 0000000..4f4eecb --- /dev/null +++ b/server/src/routes/magic.rs @@ -0,0 +1,202 @@ +//! 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")); + } +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index d44f83d..1a4a71c 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -3,6 +3,7 @@ mod case_actions; mod debug; mod health; mod login; +mod magic; mod oneliners; mod upload; pub(crate) mod user_web; @@ -19,6 +20,8 @@ pub fn api_router() -> Router { .route("/api/debug/whoami", get(debug::handle_whoami)) .route("/api/upload", post(upload::handle_upload)) .route("/api/oneliners", get(oneliners::handle_oneliners)) + .route("/api/auth/magic-link", post(magic::handle_create)) + .route("/web/magic", get(magic::handle_consume)) .route( "/web/login", get(login::handle_login_page).post(login::handle_login_submit), diff --git a/server/src/web_session.rs b/server/src/web_session.rs index 7a05c4d..9c2e1b9 100644 --- a/server/src/web_session.rs +++ b/server/src/web_session.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use rand::Rng; +use rand::distributions::Alphanumeric; use tokio::sync::RwLock; pub struct WebSession { @@ -15,3 +18,33 @@ pub type SessionStore = Arc>>; pub fn new_store() -> SessionStore { Arc::new(RwLock::new(HashMap::new())) } + +/// Cookie name shared by login and magic-link flows. +pub const SESSION_COOKIE: &str = "session"; + +/// 43 alphanumeric chars ≈ 256 bits of entropy — enough for a session +/// or short-lived magic-link token. +pub const TOKEN_LEN: usize = 43; + +/// CSPRNG-backed token. Used for both long-lived session cookies and +/// short-lived magic-link tokens; both need the same entropy. +pub fn generate_token() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(TOKEN_LEN) + .map(char::from) + .collect() +} + +/// Builds a `HttpOnly + SameSite=Strict + Path=/` cookie for the session +/// token. `secure` is wired from `Config::cookie_secure` so local HTTP dev +/// can opt out — browsers refuse `Secure` cookies on non-HTTPS origins. +pub fn build_session_cookie(value: String, max_age: Duration, secure: bool) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE, value)) + .http_only(true) + .secure(secure) + .same_site(SameSite::Strict) + .path("/") + .max_age(time::Duration::seconds(max_age.as_secs() as i64)) + .build() +} diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs new file mode 100644 index 0000000..747ebad --- /dev/null +++ b/server/tests/magic_link_test.rs @@ -0,0 +1,252 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use serde_json::json; +use tower::util::ServiceExt; + +use doctate_common::API_KEY_HEADER; +use doctate_server::config::{Config, User}; +use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink}; +use doctate_server::{AnalyzeBusy, AppState, TranscribeBusy, analyze, transcribe, web_session}; + +const API_KEY: &str = "key-dr_a"; + +fn make_user() -> User { + User { + slug: "dr_a".into(), + api_key: API_KEY.into(), + web_password: bcrypt::hash("unused", 4).unwrap(), + role: "doctor".into(), + whisper: Default::default(), + } +} + +fn build_state() -> (AppState, Arc) { + let user = make_user(); + let data_path = std::env::temp_dir().join(format!( + "doctate-magic-test-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let mut api_keys: HashMap = HashMap::new(); + api_keys.insert(user.api_key.clone(), user.slug.clone()); + + let config = Arc::new(Config { + data_path, + users: vec![user], + api_keys, + ..Config::test_default() + }); + + let (transcribe_tx, _transcribe_rx) = transcribe::channel(); + let (analyze_tx, _analyze_rx) = analyze::channel(); + let magic_link_store = doctate_server::magic_link::new_store(); + + let state = AppState { + config: config.clone(), + transcribe_tx, + analyze_tx, + session_store: web_session::new_store(), + magic_link_store, + analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), + transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), + }; + + (state, config) +} + +fn create_request(body: &str, api_key: Option<&str>) -> Request { + let mut b = Request::builder() + .method("POST") + .uri("/api/auth/magic-link") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(k) = api_key { + b = b.header(API_KEY_HEADER, k); + } + b.body(Body::from(body.to_owned())).unwrap() +} + +fn consume_request(token: &str) -> Request { + Request::builder() + .method("GET") + .uri(format!("/web/magic?token={token}")) + .body(Body::empty()) + .unwrap() +} + +async fn body_json(response: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +#[tokio::test] +async fn create_without_api_key_returns_401() { + let (state, _) = build_state(); + let app = doctate_server::create_router_with_state(state); + + let resp = app + .oneshot(create_request("{}", None)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn create_with_valid_key_returns_token() { + let (state, _) = build_state(); + let app = doctate_server::create_router_with_state(state); + + let resp = app + .oneshot(create_request( + r#"{"return_to":"/web/cases/abc"}"#, + Some(API_KEY), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + let token = body["token"].as_str().expect("token field"); + assert!(token.len() >= 30, "token looks short: {token}"); +} + +#[tokio::test] +async fn consume_valid_token_sets_cookie_and_redirects() { + let (state, _) = build_state(); + let app = doctate_server::create_router_with_state(state); + + // Step 1: issue token via API. + let create_resp = app + .clone() + .oneshot(create_request( + r#"{"return_to":"/web/cases/abc"}"#, + Some(API_KEY), + )) + .await + .unwrap(); + let token = body_json(create_resp).await["token"] + .as_str() + .unwrap() + .to_owned(); + + // Step 2: consume. + let resp = app.oneshot(consume_request(&token)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!( + resp.headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), + "/web/cases/abc" + ); + assert_eq!( + resp.headers() + .get(header::REFERRER_POLICY) + .unwrap() + .to_str() + .unwrap(), + "no-referrer" + ); + + // Cookie must be set with HttpOnly and SameSite=Strict. + let cookie = resp + .headers() + .get(header::SET_COOKIE) + .expect("session cookie missing") + .to_str() + .unwrap(); + assert!(cookie.starts_with("session=")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); +} + +#[tokio::test] +async fn consume_token_is_one_time_use() { + let (state, _) = build_state(); + let app = doctate_server::create_router_with_state(state); + + let token = body_json( + app.clone() + .oneshot(create_request("{}", Some(API_KEY))) + .await + .unwrap(), + ) + .await["token"] + .as_str() + .unwrap() + .to_owned(); + + let first = app.clone().oneshot(consume_request(&token)).await.unwrap(); + assert_eq!(first.status(), StatusCode::SEE_OTHER); + assert_eq!( + first.headers().get(header::LOCATION).unwrap(), + "/web/cases" // default + ); + + let second = app.oneshot(consume_request(&token)).await.unwrap(); + // Second use: token gone, redirect to login. + assert_eq!(second.status(), StatusCode::SEE_OTHER); + assert_eq!(second.headers().get(header::LOCATION).unwrap(), "/web/login"); +} + +#[tokio::test] +async fn create_rejects_open_redirect_return_to() { + let (state, _) = build_state(); + let app = doctate_server::create_router_with_state(state); + + for evil in [ + "https://evil.com/", + "//evil.com/", + "/api/upload", + "javascript:alert(1)", + ] { + let body = json!({ "return_to": evil }).to_string(); + let resp = app + .clone() + .oneshot(create_request(&body, Some(API_KEY))) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "should reject return_to={evil}" + ); + } +} + +#[tokio::test] +async fn consume_expired_token_redirects_to_login() { + let (state, _) = build_state(); + let store: MagicLinkStore = state.magic_link_store.clone(); + + // Insert a token that expired 1s ago — bypassing the API to control time. + let token = "test-token-expired".to_owned(); + { + let mut w = store.write().await; + w.insert( + token.clone(), + PendingMagicLink { + slug: "dr_a".into(), + role: "doctor".into(), + return_to: "/web/cases".into(), + expires_at: Instant::now() - Duration::from_secs(1), + }, + ); + } + + let app = doctate_server::create_router_with_state(state); + let resp = app.oneshot(consume_request(&token)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/login"); + + // And the token must be removed from the store, even though it expired. + assert!(store.read().await.get(&token).is_none()); +} +