From 1275e8217e0148fc2e5e0cc9ca3739e39a8954e6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 16 Oct 2026 22:26:22 -0400 Subject: [PATCH] fix: forward the pre-auth token on magic link verify and serve the confirm route GET /magic-link/verify/{token} now forwards the requesting device's pre-auth token when one is available: the client's bearer token in bearer transport, or the pre-auth cookie's token in cookie transport when it verifies. Without one it sends no Authorization and does not answer 401. The bundled manifest is synced from seamless-auth-api, adding POST /magic-link/verify/{token}/confirm. --- README.md | 14 +++++ manifest.json | 3 +- src/routes.rs | 37 +++++++++-- src/tests/magic_link.rs | 136 ++++++++++++++++++++++++++++++++++++++++ src/tests/mod.rs | 1 + 5 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 src/tests/magic_link.rs diff --git a/README.md b/README.md index 72d2c58..9f291d6 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,20 @@ asks the API for the message and hands it to you: A delivery error answers the request with 502 `delivery_failed`. +### Magic links + +The auth API signs a user in from a magic link only on the device that asked for it. On +`GET /magic-link/verify/{token}` the adapter forwards that device's pre-auth token when it has +one: the client's own bearer token in bearer transport, or the pre-auth cookie in cookie +transport if it verifies. Without one it sends none, and never answers 401, because the API +falls back to the client address and user agent the adapter forwards. + +Opened anywhere else, the link answers 409 `confirmation_required`, with `requestedFrom` +(`deviceClass`, `requestedAt`) describing the request, and is not consumed. Once the user +approves it, `POST /magic-link/verify/{token}/confirm` consumes it. The requesting attempt's +first `GET /magic-link/check` after that issues the session; later checks answer +`{ "message": "Success" }` with no tokens, and the adapter then leaves the cookies as they are. + ### The admin console `console_router()` serves the Seamless admin dashboard from your API, proxied from the auth API, so diff --git a/manifest.json b/manifest.json index 8971c13..936bc31 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "apiVersion": "0.17.0", + "apiVersion": "0.18.0", "session": { "subject": "sub", "token": "token", @@ -98,6 +98,7 @@ { "method": "POST", "path": "/magic-link", "credential": "preAuth", "delivery": true }, { "method": "GET", "path": "/magic-link/check", "credential": "preAuth", "issues": "session" }, { "method": "GET", "path": "/magic-link/verify/{token}", "credential": "none" }, + { "method": "POST", "path": "/magic-link/verify/{token}/confirm", "credential": "none" }, { "method": "POST", "path": "/oauth/{providerId}/callback", diff --git a/src/routes.rs b/src/routes.rs index d639f53..6e49042 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -18,6 +18,11 @@ use crate::jwt::{Claims, str_claim}; use crate::manifest::RouteMatch; use crate::refresh::SilentRefreshError; +// The auth API consumes a magic link on the device that requested it, which it +// recognises by that flow's pre-auth token. The token is optional: without it +// the API asks for confirmation instead, so its absence is never a 401 here. +const MAGIC_LINK_VERIFY: (&str, &str) = ("GET", "/magic-link/verify/{token}"); + /// What the adapter sends for a route, and any cookies a silent refresh /// produced on the way. pub(crate) struct Credential { @@ -106,6 +111,22 @@ impl Inner { Err(Box::new(Reply::error(StatusCode::UNAUTHORIZED, &message))) } + fn optional_pre_auth(&self, headers: &HeaderMap, bearer: bool) -> Credential { + let token = if bearer { + bearer_token(headers).map(|token| token.to_string()) + } else { + let name = self.config.cookie_name("preAuth"); + read_cookie(&self.config, headers, name, cookie_kind("preAuth")) + .map(|payload| str_claim(&payload, "token").to_string()) + }; + Credential { + authorization: token + .filter(|token| !token.is_empty()) + .map(|token| format!("Bearer {token}")), + set: Vec::new(), + } + } + /// Checks the token in a session response: signed by the auth API, of the /// expected type, and belonging to the subject the body names. Returns the /// session id. A response that fails any of these is one this adapter cannot @@ -162,12 +183,16 @@ impl Inner { return Reply::error(StatusCode::UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type"); } - let credential = match self - .credential_for(&parts.headers, forwarded, &route.credential, bearer) - .await - { - Ok(credential) => credential, - Err(rejected) => return *rejected, + let credential = if (route.method.as_str(), route.path.as_str()) == MAGIC_LINK_VERIFY { + self.optional_pre_auth(&parts.headers, bearer) + } else { + match self + .credential_for(&parts.headers, forwarded, &route.credential, bearer) + .await + { + Ok(credential) => credential, + Err(rejected) => return *rejected, + } }; let set = credential.set; diff --git a/src/tests/magic_link.rs b/src/tests/magic_link.rs new file mode 100644 index 0000000..2385d9b --- /dev/null +++ b/src/tests/magic_link.rs @@ -0,0 +1,136 @@ +use serde_json::json; + +use super::*; + +const VERIFY: &str = "/magic-link/verify/abc"; + +fn ok() -> Value { + json!({ "message": "Success" }) +} + +#[tokio::test] +async fn verify_forwards_the_client_bearer() { + let api = FakeApi::start().await; + api.on("GET", VERIFY, respond(200, ok())); + + let r = call("GET", VERIFY) + .header("x-seamless-auth-transport", "bearer") + .header("authorization", "Bearer pre-auth") + .to(&api.adapter(|b| b)) + .await; + + assert_eq!(r.status, 200, "{}", r.text); + assert_eq!( + api.calls_to("GET", VERIFY)[0].header("authorization"), + "Bearer pre-auth" + ); +} + +#[tokio::test] +async fn verify_forwards_a_valid_pre_auth_cookie_as_bearer() { + let api = FakeApi::start().await; + api.on("GET", VERIFY, respond(200, ok())); + + let r = call("GET", VERIFY) + .cookie(&signed_cookie( + "seamless-ephemeral", + json!({ "sub": "u1", "token": "pre-auth" }), + )) + .to(&api.adapter(|b| b)) + .await; + + assert_eq!(r.status, 200, "{}", r.text); + assert_eq!( + api.calls_to("GET", VERIFY)[0].header("authorization"), + "Bearer pre-auth" + ); +} + +#[tokio::test] +async fn verify_without_a_usable_pre_auth_sends_no_authorization() { + let wrong_kind = raw_cookie( + "seamless-ephemeral", + crate::cookies::KIND_ACCESS, + json!({ "sub": "u1", "token": "t" }), + ); + let cases: [(&str, Vec<(&str, &str)>); 4] = [ + ("no cookie", vec![]), + ( + "altered cookie", + vec![("cookie", "seamless-ephemeral=not-a-signed-cookie")], + ), + ("wrong kind", vec![("cookie", wrong_kind.as_str())]), + ( + "bearer without token", + vec![("x-seamless-auth-transport", "bearer")], + ), + ]; + for (name, headers) in cases { + let api = FakeApi::start().await; + api.on("GET", VERIFY, respond(200, ok())); + + let mut request = call("GET", VERIFY); + for (k, v) in headers { + request = request.header(k, v); + } + let r = request.to(&api.adapter(|b| b)).await; + + assert_eq!(r.status, 200, "{name}: {}", r.text); + let calls = api.calls_to("GET", VERIFY); + assert_eq!(calls.len(), 1, "{name}"); + assert!( + calls[0].headers.get("authorization").is_none(), + "{name}: sent {}", + calls[0].header("authorization") + ); + } +} + +#[tokio::test] +async fn confirmation_required_passes_through() { + let api = FakeApi::start().await; + let body = json!({ + "error": "confirmation_required", + "message": "Confirm this sign-in", + "requestedFrom": { "deviceClass": "desktop", "requestedAt": "2026-10-16T12:00:00.000Z" }, + }); + api.on("GET", VERIFY, respond(409, body.clone())); + + let r = call("GET", VERIFY).to(&api.adapter(|b| b)).await; + + assert_eq!(r.status, 409); + assert_eq!(r.body, body); +} + +#[tokio::test] +async fn confirm_is_proxied_without_a_credential() { + let api = FakeApi::start().await; + let confirm = format!("{VERIFY}/confirm"); + api.on("POST", &confirm, respond(200, ok())); + + let r = call("POST", &confirm).to(&api.adapter(|b| b)).await; + + assert_eq!(r.status, 200, "{}", r.text); + assert_eq!(r.body, ok()); + let calls = api.calls_to("POST", &confirm); + assert_eq!(calls.len(), 1); + assert!(calls[0].headers.get("authorization").is_none()); +} + +#[tokio::test] +async fn a_check_without_tokens_sets_no_cookies() { + let api = FakeApi::start().await; + api.on("GET", "/magic-link/check", respond(200, ok())); + + let r = call("GET", "/magic-link/check") + .cookie(&signed_cookie( + "seamless-ephemeral", + json!({ "sub": "u1", "token": "pre-auth" }), + )) + .to(&api.adapter(|b| b)) + .await; + + assert_eq!(r.status, 200, "{}", r.text); + assert_eq!(r.body, ok()); + assert!(r.cookies.is_empty()); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 523d7c6..ad7de87 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -4,6 +4,7 @@ mod adapter; mod client_ip; mod console; mod guard; +mod magic_link; mod manifest; use std::collections::HashMap;