Files
doctate/clients/desktop/src/magic_link.rs
T
Brummel 2c6062a53e refactor: drop /web/ URL prefix from browser routes
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
2026-05-04 18:36:10 +02:00

122 lines
3.8 KiB
Rust

//! 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, join_url};
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 `/` (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 resp = http
.post(join_url(server_url, "/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(join_url(
server_url,
&format!("/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", "/cases/abc")
.await
.expect("magic url");
assert_eq!(url, format!("{}/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", "/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", "/cases/abc")
.await
.expect("magic url");
assert!(!url.contains("//magic"), "double slash leaked: {url}");
}
}