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:
@@ -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) {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod magic_link;
|
||||
mod paths;
|
||||
mod recorder;
|
||||
mod state;
|
||||
|
||||
Reference in New Issue
Block a user