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
This commit is contained in:
2026-04-19 16:00:12 +02:00
parent 0d5c2f5888
commit 76e8ee18e9
13 changed files with 690 additions and 39 deletions
+1 -3
View File
@@ -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,
+10
View File
@@ -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<AppState> for SessionStore {
}
}
impl FromRef<AppState> for MagicLinkStore {
fn from_ref(state: &AppState) -> Self {
state.magic_link_store.clone()
}
}
impl FromRef<AppState> for AnalyzeBusy {
fn from_ref(state: &AppState) -> Self {
state.analyze_busy.clone()
@@ -135,6 +144,7 @@ pub fn create_router(config: Arc<Config>) -> 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))),
})
+27
View File
@@ -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<RwLock<HashMap<String, PendingMagicLink>>>;
pub fn new_store() -> MagicLinkStore {
Arc::new(RwLock::new(HashMap::new()))
}
+1
View File
@@ -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),
};
+9 -25
View File
@@ -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<SessionStore>,
jar: CookieJar,
) -> Result<Response, AppError> {
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<Html<String>, 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()
}
+202
View File
@@ -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<String>,
}
#[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<MagicLinkStore>,
body: Option<Json<MagicLinkRequest>>,
) -> Result<Json<MagicLinkResponse>, 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<Arc<Config>>,
State(magic_store): State<MagicLinkStore>,
State(session_store): State<SessionStore>,
jar: CookieJar,
Query(q): Query<ConsumeQuery>,
) -> 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"));
}
}
+3
View File
@@ -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<AppState> {
.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),
+34 -1
View File
@@ -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<RwLock<HashMap<String, WebSession>>>;
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()
}
+252
View File
@@ -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<Config>) {
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<String, String> = 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<Body> {
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<Body> {
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());
}