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 @@ -167,6 +167,20 @@ auth = Adapter(..., deliver=deliver)
An `async def` callback works too; under FastAPI it runs on the application's event loop. 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

The adapter can serve the Seamless admin dashboard from your API, proxied from the auth API, so it
Expand Down
28 changes: 24 additions & 4 deletions src/seamless_auth/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
# token and never presents one that expires in flight.
PROXY_REUSE = 45.0

# 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.
_MAGIC_LINK_VERIFY = ("GET", "/magic-link/verify/{token}")

_COOKIE_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
_COOKIE_DOMAIN = re.compile(r"^[A-Za-z0-9.\-]+$")
_TOKEN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
Expand Down Expand Up @@ -428,6 +433,16 @@ def _credential_for(
return _error(401, f"Invalid or expired {name} cookie")
return _error(401, f'Missing required cookie "{name}"')

def _optional_pre_auth(self, headers: Headers, bearer: bool) -> str | None:
if bearer:
token = _bearer_token(headers)
else:
name = self._config.cookie_name("preAuth")
token = str_claim(
read_cookie(self._config, headers, name, cookie_kind("preAuth")), "token"
)
return f"Bearer {token}" if token else None

def _verify_session(self, session: Claims, typ: str) -> str:
"""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
Expand Down Expand Up @@ -458,10 +473,15 @@ def _manifest_route(
if body is not None and not _is_explicit_json(request.headers.get("content-type")):
return _error(415, "unsupported_media_type")

credential = self._credential_for(request.headers, forwarded, route.credential, bearer)
if isinstance(credential, _Reply):
return credential
authorization, cookies = credential
authorization: str | None
cookies: list[CookieWrite]
if (route.method, route.path) == _MAGIC_LINK_VERIFY:
authorization, cookies = self._optional_pre_auth(request.headers, bearer), []
else:
credential = self._credential_for(request.headers, forwarded, route.credential, bearer)
if isinstance(credential, _Reply):
return credential
authorization, cookies = credential

external = route.delivery and self._deliver is not None
try:
Expand Down
3 changes: 2 additions & 1 deletion src/seamless_auth/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
88 changes: 88 additions & 0 deletions tests/test_magic_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from __future__ import annotations

import pytest
from conftest import FakeApi, call, pre_auth, raw_cookie, respond

VERIFY = "/magic-link/verify/abc"


def test_verify_forwards_the_client_bearer(api: FakeApi) -> None:
api.on("GET", VERIFY, respond(200, {"message": "Success"}))

r = call(
api.adapter(),
"GET",
VERIFY,
headers={"x-seamless-auth-transport": "bearer", "authorization": "Bearer pre-auth"},
)

assert r.status == 200, r.text
assert api.calls_to("GET", VERIFY)[0].headers["authorization"] == "Bearer pre-auth"


def test_verify_forwards_a_valid_pre_auth_cookie_as_bearer(api: FakeApi) -> None:
api.on("GET", VERIFY, respond(200, {"message": "Success"}))

r = call(api.adapter(), "GET", VERIFY, cookie=pre_auth())

assert r.status == 200, r.text
assert api.calls_to("GET", VERIFY)[0].headers["authorization"] == "Bearer pre-auth"


@pytest.mark.parametrize(
("cookie", "headers"),
[
(None, None),
("seamless-ephemeral=not-a-signed-cookie", None),
(raw_cookie("seamless-ephemeral", "access", {"sub": "u1", "token": "t"}), None),
(None, {"x-seamless-auth-transport": "bearer"}),
],
ids=["no cookie", "altered cookie", "wrong kind", "bearer without token"],
)
def test_verify_without_a_usable_pre_auth_sends_no_authorization(
api: FakeApi, cookie: str | None, headers: dict[str, str] | None
) -> None:
api.on("GET", VERIFY, respond(200, {"message": "Success"}))

r = call(api.adapter(), "GET", VERIFY, cookie=cookie, headers=headers)

assert r.status == 200, r.text
calls = api.calls_to("GET", VERIFY)
assert len(calls) == 1
assert "authorization" not in calls[0].headers


def test_confirmation_required_passes_through(api: FakeApi) -> None:
body = {
"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))

r = call(api.adapter(), "GET", VERIFY)

assert r.status == 409
assert r.body == body


def test_confirm_is_proxied_without_a_credential(api: FakeApi) -> None:
api.on("POST", f"{VERIFY}/confirm", respond(200, {"message": "Success"}))

r = call(api.adapter(), "POST", f"{VERIFY}/confirm")

assert r.status == 200, r.text
assert r.body == {"message": "Success"}
calls = api.calls_to("POST", f"{VERIFY}/confirm")
assert len(calls) == 1
assert "authorization" not in calls[0].headers


def test_a_check_without_tokens_sets_no_cookies(api: FakeApi) -> None:
api.on("GET", "/magic-link/check", respond(200, {"message": "Success"}))

r = call(api.adapter(), "GET", "/magic-link/check", cookie=pre_auth())

assert r.status == 200, r.text
assert r.body == {"message": "Success"}
assert not r.cookies
Loading