Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"apiVersion": "0.17.0",
"apiVersion": "0.18.0",
"session": {
"subject": "sub",
"token": "token",
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 31 additions & 6 deletions src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
136 changes: 136 additions & 0 deletions src/tests/magic_link.rs
Original file line number Diff line number Diff line change
@@ -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());
}
1 change: 1 addition & 0 deletions src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod adapter;
mod client_ip;
mod console;
mod guard;
mod magic_link;
mod manifest;

use std::collections::HashMap;
Expand Down
Loading