diff --git a/README.md b/README.md index a1b827e..c7d5cfc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index c32806a..1c3a9f4 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -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]+$") @@ -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 @@ -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: diff --git a/src/seamless_auth/manifest.json b/src/seamless_auth/manifest.json index 8971c13..936bc31 100644 --- a/src/seamless_auth/manifest.json +++ b/src/seamless_auth/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/tests/test_magic_link.py b/tests/test_magic_link.py new file mode 100644 index 0000000..cbebbfc --- /dev/null +++ b/tests/test_magic_link.py @@ -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