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
+27 -8
View File
@@ -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 ~100500ms.
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) {
+120
View File
@@ -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<String, MagicLinkError> {
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}");
}
}
+1
View File
@@ -1,5 +1,6 @@
mod app;
mod config;
mod magic_link;
mod paths;
mod recorder;
mod state;