From 7c05a836801677ab265238d78c3776e6cb4fcdf9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:26:24 -0500 Subject: [PATCH 01/22] test: reproduce cross-site form posts and JWKS lock contention A body-less form POST with Sec-Fetch-Site same-site or absent reaches cookie-authenticated routes under SameSite=Lax, and under SameSite=None without an allowlist. The allowlist is not enforced unless SameSite=None. A verification of a cached, fresh kid waits on a slow JWKS refetch. Refs #25 Refs #24 --- tests/test_adapter.py | 42 +++++++++++++++++++++++ tests/test_guard.py | 19 +++++++++++ tests/test_jwks.py | 77 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 tests/test_jwks.py diff --git a/tests/test_adapter.py b/tests/test_adapter.py index abfe8b8..5949e90 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -548,6 +548,7 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: APP = "https://app.example" +SIBLING = "https://evil.app.example" ORIGIN_CASES = [ # (allowlist, same_site, method, sec-fetch-site, origin, blocked) ((), "None", "POST", "cross-site", None, True), @@ -601,6 +602,47 @@ def test_the_origin_check(api: FakeApi, case: OriginCase) -> None: assert r.status != 403, r.text +# Cookie-authenticated routes a forged form POST can drive with no body at all. +FORM_POST_TARGETS = ["/recovery-codes", "/totp/enroll/start", "/admin/applications/app-1/secret"] + + +@pytest.mark.parametrize("path", FORM_POST_TARGETS) +@pytest.mark.parametrize("site", ["same-site", None]) +@pytest.mark.parametrize("same_site", ["Lax", "None"]) +def test_a_body_less_form_post_does_not_reach_a_cookie_route( + api: FakeApi, path: str, site: str | None, same_site: str +) -> None: + api.on("POST", path, respond(200, {"ok": True})) + adapter = api.adapter(same_site=same_site) + # What a sibling subdomain's
sends: no body, a form + # content type, and either same-site fetch metadata or none at all. + headers = {"content-type": "application/x-www-form-urlencoded", "origin": SIBLING} + if site is not None: + headers["sec-fetch-site"] = site + + r = call(adapter, "POST", path, cookie=access(), headers=headers) + + assert r.status in (403, 415), r.text + assert not api.calls_to("POST", path) + + +@pytest.mark.parametrize("site", ["same-site", None, "cross-site"]) +@pytest.mark.parametrize("same_site", ["Lax", "Strict"]) +def test_the_allowlist_holds_whatever_the_same_site_setting( + api: FakeApi, site: str | None, same_site: str +) -> None: + api.on("POST", "/recovery-codes", respond(200, {"ok": True})) + adapter = api.adapter(allowed_origins=[APP], same_site=same_site) + headers = {"origin": SIBLING} + if site is not None: + headers["sec-fetch-site"] = site + + r = call(adapter, "POST", "/recovery-codes", body="{}", cookie=access(), headers=headers) + + assert (r.status, r.body) == (403, {"error": "cross_site_request_blocked"}) + assert not api.calls_to("POST", "/recovery-codes") + + def test_the_live_manifest_adds_routes(api: FakeApi) -> None: api.manifest = { "schemaVersion": 1, diff --git a/tests/test_guard.py b/tests/test_guard.py index ba1ded9..d69bc6a 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -368,6 +368,25 @@ def post(*headers: tuple[str, str]) -> str: ) +@pytest.mark.parametrize("same_site", ["Lax", "Strict"]) +def test_the_guard_checks_the_origin_whatever_the_same_site_setting( + api: FakeApi, same_site: str +) -> None: + adapter = api.adapter(allowed_origins=["https://app.example"], same_site=same_site) + session = session_cookie(ACCESS) + + def post(*headers: tuple[str, str]) -> str: + return adapter.authenticate_request("POST", [session, *headers]).id + + with pytest.raises(CrossSiteRequest): + post(("sec-fetch-site", "cross-site")) + with pytest.raises(CrossSiteRequest): + post(("sec-fetch-site", "same-site"), ("origin", "https://evil.app.example")) + with pytest.raises(CrossSiteRequest): + post(("origin", "https://evil.app.example")) + assert post(("sec-fetch-site", "same-site"), ("origin", "https://app.example")) == "u1" + + ROLE_CASES = [ # (granted, required, ok) (["admin"], ["admin"], True), diff --git a/tests/test_jwks.py b/tests/test_jwks.py new file mode 100644 index 0000000..6f9b61e --- /dev/null +++ b/tests/test_jwks.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import contextlib +import threading +import time + +import httpx2 +from conftest import API_URL, JWKS, api_token + +from seamless_auth._jwks import REFETCH_INTERVAL, JwksCache, TokenError + +JWKS_URL = f"{API_URL}/.well-known/jwks.json" + + +class SlowJwks: + """A JWKS endpoint whose fetches after the first hang until released.""" + + def __init__(self) -> None: + self.fetches = 0 + self.started = threading.Event() + self.release = threading.Event() + + def handle(self, _request: httpx2.Request) -> httpx2.Response: + self.fetches += 1 + if self.fetches > 1: + self.started.set() + self.release.wait(10) + return httpx2.Response(200, json=JWKS) + + +def test_a_cached_kid_does_not_wait_on_a_fetch_in_progress() -> None: + endpoint = SlowJwks() + cache = JwksCache(JWKS_URL, httpx2.Client(transport=httpx2.MockTransport(endpoint.handle))) + token = api_token("u1", "access") + cache.verify(token, API_URL, API_URL) + # Past the refetch interval, so an unknown kid is allowed a refetch. + cache._attempted_at = time.monotonic() - REFETCH_INTERVAL - 1 + + unknown = threading.Thread(target=lambda: _swallow(cache, "random-kid")) + unknown.start() + try: + assert endpoint.started.wait(5), "the refetch for the unknown kid never started" + + done = threading.Event() + verified = threading.Thread( + target=lambda: (cache.verify(token, API_URL, API_URL), done.set()) + ) + verified.start() + assert done.wait(1), "a token with a cached, fresh kid waited on the slow fetch" + verified.join(5) + finally: + endpoint.release.set() + unknown.join(10) + + +def test_concurrent_misses_share_one_fetch() -> None: + endpoint = SlowJwks() + cache = JwksCache(JWKS_URL, httpx2.Client(transport=httpx2.MockTransport(endpoint.handle))) + cache.verify(api_token("u1", "access"), API_URL, API_URL) + cache._attempted_at = time.monotonic() - REFETCH_INTERVAL - 1 + + threads = [threading.Thread(target=lambda: _swallow(cache, "random-kid")) for _ in range(5)] + for t in threads: + t.start() + try: + assert endpoint.started.wait(5) + time.sleep(0.2) + finally: + endpoint.release.set() + for t in threads: + t.join(10) + assert endpoint.fetches == 2, "the misses refetched once between them" + + +def _swallow(cache: JwksCache, kid: str) -> None: + with contextlib.suppress(TokenError): + cache.key(kid) From 8960e32979a3229b9f23a08db6bbbdae3b6dc04a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:27:05 -0500 Subject: [PATCH 02/22] fix: do not hold the JWKS cache lock across the network fetch A verification whose kid is cached is served from the cache without waiting on a fetch in progress. A refetch runs single-flight with the lock released: concurrent misses wait for the one fetch and then read its result, so a slow JWKS endpoint no longer queues every verification in the process behind it. Refs #24 --- src/seamless_auth/_jwks.py | 73 +++++++++++++++++++++++++++----------- tests/test_jwks.py | 18 ++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/seamless_auth/_jwks.py b/src/seamless_auth/_jwks.py index 18b3de7..188f1ad 100644 --- a/src/seamless_auth/_jwks.py +++ b/src/seamless_auth/_jwks.py @@ -52,30 +52,61 @@ def __init__(self, url: str, client: httpx2.Client) -> None: self._keys: dict[str, Jwk] | None = None self._fetched_at: float | None = None self._attempted_at: float | None = None + # Set while a fetch is in flight; waiters block on it, never on the lock. + self._fetching: threading.Event | None = None def key(self, kid: str) -> Jwk: - with self._lock: - now = time.monotonic() - fresh = self._fetched_at is not None and now - self._fetched_at < MAX_AGE - if fresh and self._keys is not None and kid in self._keys: - return self._keys[kid] - - backoff = REFETCH_INTERVAL if self._keys is not None else FAILED_FETCH_BACKOFF - if self._attempted_at is None or now - self._attempted_at >= backoff: - self._attempted_at = now - try: - self._keys = self._fetch() + # The lock guards the cache only and is never held across the network: a + # slow JWKS endpoint must not queue verifications of keys already known. + while True: + with self._lock: + now = time.monotonic() + keys = self._keys + fresh = self._fetched_at is not None and now - self._fetched_at < MAX_AGE + if keys is not None and kid in keys and (fresh or self._fetching is not None): + return keys[kid] + in_flight = self._fetching + if in_flight is None: + backoff = REFETCH_INTERVAL if keys is not None else FAILED_FETCH_BACKOFF + if self._attempted_at is not None and now - self._attempted_at < backoff: + if keys is None or kid not in keys: + raise TokenError(f"unknown key id {kid!r}") + return keys[kid] + self._attempted_at = now + self._fetching = threading.Event() + if in_flight is not None: + # Single flight: wait for the fetch another caller started, then + # look again (within the backoff, so this cannot start a second). + in_flight.wait() + continue + return self._refresh(kid) + + def _refresh(self, kid: str) -> Jwk: + fetched: dict[str, Jwk] | None = None + error: Exception | None = None + try: + fetched = self._fetch() + except Exception as err: + error = err + finally: + # Released whatever happens, or every waiter would block for good. + with self._lock: + if fetched is not None: + self._keys = fetched self._fetched_at = time.monotonic() - except Exception as err: - # A stale key set keeps verifying while the API cannot be - # reached, rather than signing every user out. - if self._keys is None: - raise TokenError(f"jwks: {err}") from err - log.warning("[seamless-auth] Could not refresh the JWKS (%s).", err) - - if self._keys is None or kid not in self._keys: - raise TokenError(f"unknown key id {kid!r}") - return self._keys[kid] + done, self._fetching = self._fetching, None + keys = self._keys + if done is not None: + done.set() + if error is not None: + # A stale key set keeps verifying while the API cannot be reached, + # rather than signing every user out. + if keys is None: + raise TokenError(f"jwks: {error}") from error + log.warning("[seamless-auth] Could not refresh the JWKS (%s).", error) + if keys is None or kid not in keys: + raise TokenError(f"unknown key id {kid!r}") + return keys[kid] def _fetch(self) -> dict[str, Jwk]: with self._client.stream("GET", self._url, timeout=FETCH_TIMEOUT) as res: diff --git a/tests/test_jwks.py b/tests/test_jwks.py index 6f9b61e..b28b1e4 100644 --- a/tests/test_jwks.py +++ b/tests/test_jwks.py @@ -5,6 +5,7 @@ import time import httpx2 +import pytest from conftest import API_URL, JWKS, api_token from seamless_auth._jwks import REFETCH_INTERVAL, JwksCache, TokenError @@ -75,3 +76,20 @@ def test_concurrent_misses_share_one_fetch() -> None: def _swallow(cache: JwksCache, kid: str) -> None: with contextlib.suppress(TokenError): cache.key(kid) + + +def test_a_failed_fetch_releases_its_waiters() -> None: + fetches = 0 + + def failing(_request: httpx2.Request) -> httpx2.Response: + nonlocal fetches + fetches += 1 + return httpx2.Response(503) + + cache = JwksCache(JWKS_URL, httpx2.Client(transport=httpx2.MockTransport(failing))) + + for _ in range(2): + with pytest.raises(TokenError): + cache.key("k1") + assert fetches == 1, "a second miss inside the backoff does not fetch again" + assert cache._fetching is None From b55f8b347318dc1af3477fb966d1ccaa298eac8d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:29:04 -0500 Subject: [PATCH 03/22] fix: check Origin on unsafe methods whatever the SameSite setting A sibling subdomain is same-site, so SameSite=Lax or Strict still sends it the cookies, and a body-less form post skipped the JSON check. - The cross-site check runs on every unsafe method, not only while cookies are SameSite=None. - When allowed_origins is set, same-site requests and requests without fetch metadata must carry an Origin on the list. Origin: null is refused whether or not a list is set. - allowed_origins=[] is strict and allows no origin; leaving it unset (the default, now None) keeps the list unenforced. - A state change on a manifest route needs Content-Type: application/json or an x-seamless-auth-transport header even with no body, either of which forces a CORS preflight. Refresh and logout are unchanged. Refs #25 --- README.md | 25 +++++++----- src/seamless_auth/_adapter.py | 50 +++++++++++------------ tests/conftest.py | 9 +++-- tests/test_adapter.py | 76 +++++++++++++++++++++++++++++------ tests/test_fastapi.py | 2 +- 5 files changed, 112 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index c10c76d..752cea1 100644 --- a/README.md +++ b/README.md @@ -134,16 +134,18 @@ and audience, in date, `typ: access`, and the same `sub` as the cookie. `User.ro claims of its own even if the cookie secret leaks. The first request with a new key fetches the key set, and when it cannot be fetched the guard answers 401. -A request the guard admits on the session cookie also gets the auth routes' cross-site check, -while cookies are `SameSite=None` and only for methods other than `GET`, `HEAD` and `OPTIONS`: +A request the guard admits on the session cookie also gets the auth routes' cross-site check, for +methods other than `GET`, `HEAD` and `OPTIONS`, whatever the `same_site` setting (a sibling +subdomain is same-site, so `SameSite=Lax` or `Strict` still sends it the cookies): - `Sec-Fetch-Site: cross-site` answers 403. -- `Sec-Fetch-Site: same-site` also covers a sibling subdomain, so when `allowed_origins` is set the - request must carry an `Origin` on that list, or it answers 403. Without `allowed_origins` it - passes. - `Sec-Fetch-Site: same-origin` (or `none`) passes. -- With no `Sec-Fetch-Site`, an `Origin: null` answers 403, and when `allowed_origins` is set any - other `Origin` must be on it. A request with neither header passes. +- `Sec-Fetch-Site: same-site` also covers a sibling subdomain, and a request with no + `Sec-Fetch-Site` could come from anywhere, so for both the `Origin` decides. `Origin: null` + answers 403. When `allowed_origins` is set, the request must carry an `Origin` on that list, or + it answers 403. Without `allowed_origins` it passes, and on the auth routes the body rule below + is what stops a form. +- `allowed_origins=[]` is strict: no `Origin` is on it, so only same-origin requests pass. A bearer token is never attached by a browser, so it is not checked. For the same reason the auth routes skip the check on a bearer-transport request (`x-seamless-auth-transport: bearer`), which @@ -200,7 +202,12 @@ refresh token, so it calls `POST /auth/refresh` itself after enrolling. The auth routes forward a request body to the auth API as JSON, so a body must be sent as `application/json` (or a `+json` type). Anything else answers 415 `unsupported_media_type`: a cross-site form can post a `text/plain` body shaped like JSON with no CORS preflight, and read as -JSON it would be a sign-in the user never made. The client SDKs already send JSON. +JSON it would be a sign-in the user never made. + +A state change on an auth route (any method but `GET`, `HEAD` and `OPTIONS`) needs that content +type even with no body, or else an `x-seamless-auth-transport` header (`cookie` or `bearer`). +Either one forces a CORS preflight, which a form on a sibling subdomain cannot send, so a body-less +form post answers 415 too. `POST /auth/refresh` and `DELETE /auth/logout` are exempt. ## Options @@ -215,7 +222,7 @@ JSON it would be a sign-in the user never made. The client SDKs already send JSO | `cookie_domain` | none | Cookie `Domain` | | `insecure_cookies` | `False` | Drops `Secure`, and allows placeholder secrets, for local development over HTTP | | `same_site` | `None`, or `Lax` with `insecure_cookies` | Cookie `SameSite` | -| `allowed_origins` | none | The only cross-origin callers allowed to change state while cookies are `SameSite=None` | +| `allowed_origins` | none | The only origins allowed to change state from a same-site page or without fetch metadata; `[]` allows none | | `access_cookie_name`, `refresh_cookie_name` | `seamless-access`, `seamless-refresh` | Session cookie names | | `registration_cookie_name`, `pre_auth_cookie_name` | `seamless-ephemeral` | Sign-in flow cookie names | | `deliver` | none | Sends OTP codes and magic links through your own transports | diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index 6ff8120..871ee86 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -71,6 +71,7 @@ # 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}") +_SAFE_METHODS = ("GET", "HEAD", "OPTIONS") # Enrolling a factor from a recovery session lifts the API's restriction on the # session, but the access token keeps its recovery claim until it is refreshed. @@ -144,7 +145,7 @@ class Config: cookie_domain: str | None secure_cookies: bool same_site: str - allowed_origins: tuple[str, ...] + allowed_origins: tuple[str, ...] | None access_cookie_name: str registration_cookie_name: str refresh_cookie_name: str @@ -196,7 +197,7 @@ def __init__( cookie_domain: str | None = None, insecure_cookies: bool = False, same_site: str | None = None, - allowed_origins: Iterable[str] = (), + allowed_origins: Iterable[str] | None = None, access_cookie_name: str = "seamless-access", refresh_cookie_name: str = "seamless-refresh", # Shares the registration cookie on purpose: registration and sign-in never @@ -265,7 +266,7 @@ def __init__( cookie_domain=cookie_domain, secure_cookies=not insecure_cookies, same_site=same_site, - allowed_origins=tuple(allowed_origins), + allowed_origins=None if allowed_origins is None else tuple(allowed_origins), access_cookie_name=access_cookie_name, registration_cookie_name=registration_cookie_name, refresh_cookie_name=refresh_cookie_name, @@ -532,8 +533,13 @@ def _manifest_route( body = request.body if request.body and request.body.strip() else None # The body goes upstream as JSON, so it has to be JSON. A cross-site form can # send a text/plain body shaped like JSON with no CORS preflight; read as - # JSON, that would be a sign-in the victim never made. - if body is not None and not _is_explicit_json(request.headers.get("content-type")): + # JSON, that would be a sign-in the victim never made. A state change with no + # body needs a JSON content type or the transport header all the same: either + # one forces a preflight, which a form on a sibling subdomain cannot pass. + if route.method not in _SAFE_METHODS and not ( + _is_explicit_json(request.headers.get("content-type")) + or (body is None and request.headers.get(TRANSPORT_HEADER) is not None) + ): return _error(415, "unsupported_media_type") authorization: str | None @@ -957,32 +963,25 @@ def _verified_access_token(self, token: str) -> Claims: return claims def _check_origin(self, method: str, headers: Headers) -> _Reply | None: - """Blocks cross-site state changes while cookies are SameSite=None, which is - when the browser would otherwise attach them to such a request.""" - if self._config.same_site != "None": - return None - if method.upper() in ("GET", "HEAD", "OPTIONS"): + """Blocks cross-site state changes, whatever the SameSite setting: a sibling + subdomain is same-site, so SameSite=Lax or Strict still sends it the cookies.""" + if method.upper() in _SAFE_METHODS: return None blocked = _error(403, "cross_site_request_blocked") - origin = headers.get("origin") - site = headers.get("sec-fetch-site") - if site is not None: - site = site.lower() - if site == "cross-site": - return blocked - # same-site also covers a sibling subdomain, so an adopter who listed - # allowed origins gets the Origin held to that list. - if site != "same-site" or not self._config.allowed_origins: - return None - return None if origin is not None and self._is_allowed_origin(origin) else blocked - if origin is None: + site = (headers.get("sec-fetch-site") or "").lower() + if site == "cross-site": + return blocked + if site in ("same-origin", "none"): return None + # same-site also covers a sibling subdomain, and a request with no fetch + # metadata could come from anywhere, so the Origin is what decides. + origin = headers.get("origin") if origin == "null": return blocked - if not self._config.allowed_origins: + if self._config.allowed_origins is None: return None - return None if self._is_allowed_origin(origin) else blocked + return None if origin is not None and self._is_allowed_origin(origin) else blocked def _is_allowed_origin(self, origin: str) -> bool: normalized = _normalize_origin(origin) @@ -990,7 +989,8 @@ def _is_allowed_origin(self, origin: str) -> bool: if not normalized: return False return any( - _normalize_origin(allowed) == normalized for allowed in self._config.allowed_origins + _normalize_origin(allowed) == normalized + for allowed in self._config.allowed_origins or () ) diff --git a/tests/conftest.py b/tests/conftest.py index f371fed..b7e08ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,13 +184,16 @@ def call( headers: dict[str, str] | None = None, peer: str | None = None, ) -> Reply: - """Sends a request to the adapter's auth routes. A body goes as JSON, as the - client SDKs send it, unless ``content_type`` says otherwise.""" + """Sends a request to the adapter's auth routes. A body, or a state change with + none, goes as JSON, as the client SDKs send it, unless ``content_type`` or + ``headers`` say otherwise.""" path, _, query = path.partition("?") items = list((headers or {}).items()) if cookie: items.append(("cookie", cookie)) - if body is not None and content_type: + unsafe = method.upper() not in ("GET", "HEAD", "OPTIONS") + named = any(k.lower() == "content-type" for k, _ in items) + if (body is not None or unsafe) and content_type and not named: items.append(("content-type", content_type)) res = adapter.handle( AuthRequest( diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 5949e90..6d41496 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -551,19 +551,28 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: SIBLING = "https://evil.app.example" ORIGIN_CASES = [ # (allowlist, same_site, method, sec-fetch-site, origin, blocked) - ((), "None", "POST", "cross-site", None, True), - ((), "None", "POST", "same-origin", None, False), - ((), "None", "POST", "same-site", None, False), - ((), "None", "POST", "same-site", "https://evil.example", False), - ((), "None", "POST", None, None, False), - ((), "None", "POST", None, "null", True), - ((), "None", "POST", None, "https://evil.example", False), - ((), "None", "GET", "cross-site", None, False), - ((), "None", "OPTIONS", "cross-site", None, False), - ((), "Lax", "POST", "cross-site", None, False), - ((APP,), "Lax", "POST", "same-site", "https://evil.example", False), + (None, "None", "POST", "cross-site", None, True), + (None, "None", "POST", "same-origin", None, False), + # Without an allowlist the body gate is what stops a sibling's form. + (None, "None", "POST", "same-site", None, False), + (None, "None", "POST", "same-site", "https://evil.example", False), + (None, "None", "POST", "same-site", "null", True), + (None, "None", "POST", None, None, False), + (None, "None", "POST", None, "null", True), + (None, "None", "POST", None, "https://evil.example", False), + (None, "None", "GET", "cross-site", None, False), + (None, "None", "OPTIONS", "cross-site", None, False), + # Whatever the SameSite setting. + (None, "Lax", "POST", "cross-site", None, True), + (None, "Strict", "POST", "cross-site", None, True), + (None, "Lax", "POST", None, "null", True), + ((APP,), "Lax", "POST", "same-site", "https://evil.example", True), + ((APP,), "Lax", "POST", None, "https://evil.example", True), + ((APP,), "Strict", "POST", "same-site", APP, False), ((APP,), "None", "POST", None, APP, False), ((APP,), "None", "POST", None, "https://evil.example", True), + # With no fetch metadata the Origin is all there is to hold to the list. + ((APP,), "None", "POST", None, None, True), # A sibling subdomain is same-site, so the header alone cannot tell it from the app. ((APP,), "None", "POST", "same-site", "https://evil.example", True), ((APP,), "None", "POST", "same-site", None, True), @@ -576,6 +585,13 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: ((APP,), "None", "POST", "cross-site", APP, True), ((APP,), "None", "POST", "Cross-Site", APP, True), ((APP,), "None", "GET", "same-site", "https://evil.example", False), + # A configured empty allowlist is strict: only same-origin requests pass. + ((), "None", "POST", "same-site", APP, True), + ((), "Lax", "POST", "same-site", APP, True), + ((), "None", "POST", None, APP, True), + ((), "None", "POST", None, None, True), + ((), "None", "POST", "same-origin", None, False), + ((), "None", "GET", "same-site", APP, False), # Neither normalizes to an origin, so neither can match the other. (("not an origin",), "None", "POST", "same-site", "also not one", True), (("not an origin",), "None", "POST", None, "also not one", True), @@ -583,7 +599,7 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: ] -OriginCase = tuple[tuple[str, ...], str, str, str | None, str | None, bool] +OriginCase = tuple[tuple[str, ...] | None, str, str, str | None, str | None, bool] @pytest.mark.parametrize("case", ORIGIN_CASES) @@ -626,6 +642,42 @@ def test_a_body_less_form_post_does_not_reach_a_cookie_route( assert not api.calls_to("POST", path) +def test_a_state_change_with_no_body_needs_json_or_the_transport_header(api: FakeApi) -> None: + api.on("POST", "/recovery-codes", respond(200, {"ok": True})) + api.on("GET", "/recovery-codes", respond(200, {"ok": True})) + adapter = api.adapter() + + for content_type in (None, "text/plain", "application/x-www-form-urlencoded"): + r = call(adapter, "POST", "/recovery-codes", content_type=content_type, cookie=access()) + assert (r.status, r.body) == (415, {"error": "unsupported_media_type"}), content_type + assert not api.calls_to("POST", "/recovery-codes") + + assert call(adapter, "POST", "/recovery-codes", cookie=access()).status == 200 + for transport in ("cookie", "bearer"): + r = call( + adapter, + "POST", + "/recovery-codes", + content_type=None, + cookie=access(), + headers={"x-seamless-auth-transport": transport, "authorization": "Bearer t"}, + ) + assert r.status == 200, (transport, r.text) + # The header stands in for a content type only when there is no body to read. + r = call( + adapter, + "POST", + "/recovery-codes", + body="{}", + content_type="text/plain", + cookie=access(), + headers={"x-seamless-auth-transport": "cookie"}, + ) + assert r.status == 415 + r = call(adapter, "GET", "/recovery-codes", content_type=None, cookie=access()) + assert r.status == 200 + + @pytest.mark.parametrize("site", ["same-site", None, "cross-site"]) @pytest.mark.parametrize("same_site", ["Lax", "Strict"]) def test_the_allowlist_holds_whatever_the_same_site_setting( diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 0196ec3..7ac12cf 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -184,7 +184,7 @@ async def deliver(delivery: Delivery) -> None: client = app_for(api.adapter(deliver=deliver)) client.cookies.set("seamless-ephemeral", pre_auth().split("=", 1)[1]) - r = client.post("/auth/magic-link") + r = client.post("/auth/magic-link", headers={"content-type": "application/json"}) assert r.status_code == 200 assert r.json() == {"message": "sent"} From 400a4fe1746c2fb47bf7be54d7188c9e0e9ca891 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:30:26 -0500 Subject: [PATCH 04/22] test: pin the origin check on refresh and logout Refresh and logout get the cross-site check whatever the SameSite setting, a refused request leaves the cookies alone, and neither route is held to the content-type gate. Refs #25 --- tests/test_adapter.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 6d41496..7a1120d 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -642,6 +642,29 @@ def test_a_body_less_form_post_does_not_reach_a_cookie_route( assert not api.calls_to("POST", path) +@pytest.mark.parametrize("same_site", ["Lax", "Strict", "None"]) +@pytest.mark.parametrize(("method", "path"), [("POST", "/refresh"), ("DELETE", "/logout")]) +def test_refresh_and_logout_get_the_origin_check_and_keep_the_cookies( + api: FakeApi, same_site: str, method: str, path: str +) -> None: + api.on("POST", "/refresh", respond(200, session())) + api.on("DELETE", "/logout", respond(204, None)) + adapter = api.adapter(allowed_origins=[APP], same_site=same_site) + for headers in ( + {"sec-fetch-site": "cross-site"}, + {"sec-fetch-site": "same-site", "origin": SIBLING}, + {"origin": SIBLING}, + ): + r = call(adapter, method, path, content_type=None, cookie=REFRESH, headers=headers) + assert (r.status, r.body) == (403, {"error": "cross_site_request_blocked"}), headers + assert not r.cookies, "a refused request must not clear the session" + assert not api.calls_to(method, path) + + # Exempt from the content-type gate: the client sends both with no body. + r = call(adapter, method, path, content_type=None, cookie=REFRESH) + assert r.status != 415, r.text + + def test_a_state_change_with_no_body_needs_json_or_the_transport_header(api: FakeApi) -> None: api.on("POST", "/recovery-codes", respond(200, {"ok": True})) api.on("GET", "/recovery-codes", respond(200, {"ok": True})) From 92de5ea1aba9df81d99ff7235705a6d10402e991 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:31:36 -0500 Subject: [PATCH 05/22] test: pin logout/all to the origin check and out of the body gate DELETE /logout/all is sent body-less with no header by the client, so like refresh and logout it is exempt from the content-type gate, while still getting the cross-site check and clearing nothing when refused. Refs #25 --- README.md | 3 ++- tests/test_adapter.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 752cea1..8cefce3 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,8 @@ JSON it would be a sign-in the user never made. A state change on an auth route (any method but `GET`, `HEAD` and `OPTIONS`) needs that content type even with no body, or else an `x-seamless-auth-transport` header (`cookie` or `bearer`). Either one forces a CORS preflight, which a form on a sibling subdomain cannot send, so a body-less -form post answers 415 too. `POST /auth/refresh` and `DELETE /auth/logout` are exempt. +form post answers 415 too. `POST /auth/refresh`, `DELETE /auth/logout` and +`DELETE /auth/logout/all` are exempt, though they still get the cross-site check. ## Options diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 7a1120d..52b0109 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -643,12 +643,15 @@ def test_a_body_less_form_post_does_not_reach_a_cookie_route( @pytest.mark.parametrize("same_site", ["Lax", "Strict", "None"]) -@pytest.mark.parametrize(("method", "path"), [("POST", "/refresh"), ("DELETE", "/logout")]) +@pytest.mark.parametrize( + ("method", "path"), [("POST", "/refresh"), ("DELETE", "/logout"), ("DELETE", "/logout/all")] +) def test_refresh_and_logout_get_the_origin_check_and_keep_the_cookies( api: FakeApi, same_site: str, method: str, path: str ) -> None: api.on("POST", "/refresh", respond(200, session())) api.on("DELETE", "/logout", respond(204, None)) + api.on("DELETE", "/logout/all", respond(204, None)) adapter = api.adapter(allowed_origins=[APP], same_site=same_site) for headers in ( {"sec-fetch-site": "cross-site"}, @@ -660,7 +663,7 @@ def test_refresh_and_logout_get_the_origin_check_and_keep_the_cookies( assert not r.cookies, "a refused request must not clear the session" assert not api.calls_to(method, path) - # Exempt from the content-type gate: the client sends both with no body. + # Exempt from the content-type gate: the client sends these with no body. r = call(adapter, method, path, content_type=None, cookie=REFRESH) assert r.status != 415, r.text From fe8cd2d2709a6396bb52a17029560727a2b0be2a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:42:02 -0500 Subject: [PATCH 06/22] fix: let a request with neither fetch metadata nor Origin pass the origin check It is not a browser cross-site form, so it passes even when allowed_origins is set, matching the Node, Go and Rust adapters. Origin: null is still refused, a same-site or metadata-less request that carries an Origin must still be on the list, and the content-type gate still applies to the auth routes. Refs #25 --- README.md | 5 ++++- src/seamless_auth/_adapter.py | 5 ++++- tests/test_adapter.py | 17 ++++++++++++----- tests/test_guard.py | 2 ++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8cefce3..1b259d6 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,10 @@ subdomain is same-site, so `SameSite=Lax` or `Strict` still sends it the cookies answers 403. When `allowed_origins` is set, the request must carry an `Origin` on that list, or it answers 403. Without `allowed_origins` it passes, and on the auth routes the body rule below is what stops a form. -- `allowed_origins=[]` is strict: no `Origin` is on it, so only same-origin requests pass. +- A request with neither `Sec-Fetch-Site` nor `Origin` is not a browser form, so it passes, with + or without `allowed_origins`. On the auth routes the body rule below still applies to it. +- `allowed_origins=[]` is strict: no `Origin` is on it, so a same-site request, or one with an + `Origin` and no `Sec-Fetch-Site`, answers 403. A bearer token is never attached by a browser, so it is not checked. For the same reason the auth routes skip the check on a bearer-transport request (`x-seamless-auth-transport: bearer`), which diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index 871ee86..d0c5273 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -975,10 +975,13 @@ def _check_origin(self, method: str, headers: Headers) -> _Reply | None: if site in ("same-origin", "none"): return None # same-site also covers a sibling subdomain, and a request with no fetch - # metadata could come from anywhere, so the Origin is what decides. + # metadata could come from anywhere, so the Origin is what decides. With + # neither header it is no browser form, and the body gate still applies. origin = headers.get("origin") if origin == "null": return blocked + if origin is None and not site: + return None if self._config.allowed_origins is None: return None return None if origin is not None and self._is_allowed_origin(origin) else blocked diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 52b0109..21445da 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -571,8 +571,9 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: ((APP,), "Strict", "POST", "same-site", APP, False), ((APP,), "None", "POST", None, APP, False), ((APP,), "None", "POST", None, "https://evil.example", True), - # With no fetch metadata the Origin is all there is to hold to the list. - ((APP,), "None", "POST", None, None, True), + # Neither header is no browser form, so it passes; the body gate still applies. + ((APP,), "None", "POST", None, None, False), + ((APP,), "Lax", "POST", None, None, False), # A sibling subdomain is same-site, so the header alone cannot tell it from the app. ((APP,), "None", "POST", "same-site", "https://evil.example", True), ((APP,), "None", "POST", "same-site", None, True), @@ -589,7 +590,7 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: ((), "None", "POST", "same-site", APP, True), ((), "Lax", "POST", "same-site", APP, True), ((), "None", "POST", None, APP, True), - ((), "None", "POST", None, None, True), + ((), "None", "POST", None, None, False), ((), "None", "POST", "same-origin", None, False), ((), "None", "GET", "same-site", APP, False), # Neither normalizes to an origin, so neither can match the other. @@ -663,9 +664,10 @@ def test_refresh_and_logout_get_the_origin_check_and_keep_the_cookies( assert not r.cookies, "a refused request must not clear the session" assert not api.calls_to(method, path) - # Exempt from the content-type gate: the client sends these with no body. + # Exempt from the content-type gate: the client sends these with no body. With + # neither fetch metadata nor an Origin it passes the origin check too. r = call(adapter, method, path, content_type=None, cookie=REFRESH) - assert r.status != 415, r.text + assert r.status not in (403, 415), r.text def test_a_state_change_with_no_body_needs_json_or_the_transport_header(api: FakeApi) -> None: @@ -677,6 +679,11 @@ def test_a_state_change_with_no_body_needs_json_or_the_transport_header(api: Fak r = call(adapter, "POST", "/recovery-codes", content_type=content_type, cookie=access()) assert (r.status, r.body) == (415, {"error": "unsupported_media_type"}), content_type assert not api.calls_to("POST", "/recovery-codes") + # Passing the origin check with neither header does not skip the body gate. + strict = api.adapter(allowed_origins=[APP]) + r = call(strict, "POST", "/recovery-codes", content_type=None, cookie=access()) + assert r.status == 415 + assert not api.calls_to("POST", "/recovery-codes") assert call(adapter, "POST", "/recovery-codes", cookie=access()).status == 200 for transport in ("cookie", "bearer"): diff --git a/tests/test_guard.py b/tests/test_guard.py index d69bc6a..15d8d9d 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -385,6 +385,8 @@ def post(*headers: tuple[str, str]) -> str: with pytest.raises(CrossSiteRequest): post(("origin", "https://evil.app.example")) assert post(("sec-fetch-site", "same-site"), ("origin", "https://app.example")) == "u1" + # Neither header: not a browser form. + assert post() == "u1" ROLE_CASES = [ From 09f8bffb82d97c286deff01036fedb871039a4c8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:45:36 -0500 Subject: [PATCH 07/22] test: reproduce bearer transport skipping the origin check A cross-site or Origin: null unsafe request with x-seamless-auth-transport: bearer reaches upstream, where the Node, Go and Rust adapters refuse it. Refs #25 --- tests/test_adapter.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 21445da..e52b244 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -547,6 +547,24 @@ def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: assert (r.status, r.body) == (403, {"error": "cross_site_request_blocked"}) +@pytest.mark.parametrize("headers", [{"sec-fetch-site": "cross-site"}, {"origin": "null"}]) +def test_bearer_transport_gets_the_origin_check(api: FakeApi, headers: dict[str, str]) -> None: + api.on("POST", "/recovery-codes", respond(200, {"ok": True})) + adapter = api.adapter() + + r = call( + adapter, + "POST", + "/recovery-codes", + content_type=None, + headers={"x-seamless-auth-transport": "bearer", "authorization": "Bearer t", **headers}, + ) + + assert (r.status, r.body) == (403, {"error": "cross_site_request_blocked"}), r.text + assert not r.cookies, "a bearer reply writes no cookies" + assert not api.calls_to("POST", "/recovery-codes") + + APP = "https://app.example" SIBLING = "https://evil.app.example" ORIGIN_CASES = [ From 9354803ff669e542150f7da1305f8167b4aa1fff Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:45:54 -0500 Subject: [PATCH 08/22] fix: run the origin check on the auth routes for bearer transport too Matches the Node, Go and Rust adapters. A native bearer client sends neither fetch metadata nor an Origin, so it still passes; a browser request marked cross-site or carrying Origin: null, or an Origin off the allowlist, is refused with 403 and no cookies written. The guard is unchanged and checks only a session admitted on the cookie. Refs #25 --- README.md | 7 ++++--- src/seamless_auth/_adapter.py | 11 +++++------ tests/test_adapter.py | 24 ++++++++++++------------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1b259d6..0532624 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,10 @@ subdomain is same-site, so `SameSite=Lax` or `Strict` still sends it the cookies - `allowed_origins=[]` is strict: no `Origin` is on it, so a same-site request, or one with an `Origin` and no `Sec-Fetch-Site`, answers 403. -A bearer token is never attached by a browser, so it is not checked. For the same reason the auth -routes skip the check on a bearer-transport request (`x-seamless-auth-transport: bearer`), which -reads no cookies. +A bearer token is never attached by a browser, so the guard does not check a request it admits on +one. The auth routes check every state change, bearer transport (`x-seamless-auth-transport: +bearer`) included, as the other adapters do. A native client sends neither `Sec-Fetch-Site` nor +`Origin`, so it passes. `User.organization_id` is the active organization, from the `org_id` claim of the verified access token, or `None` when the session has none. Like `User.roles`, it never comes from the cookie. diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index d0c5273..45bb2af 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -328,12 +328,11 @@ def handle(self, request: AuthRequest) -> AuthResponse: def _handle(self, request: AuthRequest) -> AuthResponse: bearer = (request.headers.get(TRANSPORT_HEADER) or "").lower() == "bearer" - # Bearer transport reads no cookies, so a cross-site request has nothing of the - # user's to ride on. A custom header also needs a CORS preflight to be sent. - if not bearer: - blocked = self._check_origin(request.method, request.headers) - if blocked is not None: - return self._write(blocked, bearer=False) + # Both transports, as the other adapters: a native bearer client sends + # neither fetch metadata nor an Origin, so it passes. + blocked = self._check_origin(request.method, request.headers) + if blocked is not None: + return self._write(blocked, bearer=bearer) forwarded = self._forwarded(request) method = request.method.upper() diff --git a/tests/test_adapter.py b/tests/test_adapter.py index e52b244..47e89f3 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -531,20 +531,20 @@ def test_cross_site_state_changes_are_blocked(api: FakeApi) -> None: assert r.status == 400 -def test_bearer_transport_skips_the_origin_check(api: FakeApi) -> None: +def test_a_native_bearer_client_passes_the_origin_check(api: FakeApi) -> None: api.on("POST", "/login", respond(400, {"error": "invalid_request"})) adapter = api.adapter(allowed_origins=["https://app.example"]) - for headers in ({"sec-fetch-site": "cross-site"}, {"origin": "null"}): - r = call( - adapter, - "POST", - "/login", - body="{}", - headers={"x-seamless-auth-transport": "bearer", **headers}, - ) - assert r.status == 400, r.text - r = call(adapter, "POST", "/login", body="{}", headers=headers) - assert (r.status, r.body) == (403, {"error": "cross_site_request_blocked"}) + # A native client sends neither fetch metadata nor an Origin. + r = call(adapter, "POST", "/login", body="{}", headers={"x-seamless-auth-transport": "bearer"}) + assert r.status == 400, r.text + r = call( + adapter, + "POST", + "/login", + body="{}", + headers={"x-seamless-auth-transport": "bearer", "origin": "https://evil.example"}, + ) + assert (r.status, r.body) == (403, {"error": "cross_site_request_blocked"}) @pytest.mark.parametrize("headers", [{"sec-fetch-site": "cross-site"}, {"origin": "null"}]) From e207c29a5fa605f94de714b24a1086d4dd8907bf Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:48:21 -0500 Subject: [PATCH 09/22] test: reproduce Origin null passing with same-origin fetch metadata Sec-Fetch-Site: same-origin or none returns before the Origin: null check, and a padded or differently cased null is not recognized. Refs #25 --- tests/test_adapter.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 47e89f3..2d30d21 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -611,6 +611,14 @@ def test_bearer_transport_gets_the_origin_check(api: FakeApi, headers: dict[str, ((), "None", "POST", None, None, False), ((), "None", "POST", "same-origin", None, False), ((), "None", "GET", "same-site", APP, False), + # Origin: null is refused first, whatever the fetch metadata or allowlist says. + (None, "None", "POST", "same-origin", "null", True), + (None, "None", "POST", "none", "null", True), + ((APP,), "None", "POST", "same-origin", "null", True), + (None, "Lax", "POST", "same-origin", " NULL ", True), + (None, "None", "POST", None, "Null", True), + (None, "None", "POST", "same-site", " null", True), + (None, "None", "GET", "same-origin", "null", False), # Neither normalizes to an origin, so neither can match the other. (("not an origin",), "None", "POST", "same-site", "also not one", True), (("not an origin",), "None", "POST", None, "also not one", True), From e033d54cd59c2f45a4a299aad44f7377fb730697 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:48:37 -0500 Subject: [PATCH 10/22] fix: refuse Origin null before fetch metadata and the allowlist Origin: null, trimmed and in any case, is refused on every unsafe request first, as the Node adapter does, so Sec-Fetch-Site: same-origin or none no longer lets it through. Refs #25 --- README.md | 7 ++++--- src/seamless_auth/_adapter.py | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0532624..799d168 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,13 @@ A request the guard admits on the session cookie also gets the auth routes' cros methods other than `GET`, `HEAD` and `OPTIONS`, whatever the `same_site` setting (a sibling subdomain is same-site, so `SameSite=Lax` or `Strict` still sends it the cookies): +- `Origin: null` (in any case, with any surrounding space) answers 403, checked first, whatever + `Sec-Fetch-Site` and `allowed_origins` say. - `Sec-Fetch-Site: cross-site` answers 403. - `Sec-Fetch-Site: same-origin` (or `none`) passes. - `Sec-Fetch-Site: same-site` also covers a sibling subdomain, and a request with no - `Sec-Fetch-Site` could come from anywhere, so for both the `Origin` decides. `Origin: null` - answers 403. When `allowed_origins` is set, the request must carry an `Origin` on that list, or - it answers 403. Without `allowed_origins` it passes, and on the auth routes the body rule below + `Sec-Fetch-Site` could come from anywhere, so for both the `Origin` decides. When + `allowed_origins` is set, the request must carry an `Origin` on that list, or it answers 403. Without `allowed_origins` it passes, and on the auth routes the body rule below is what stops a form. - A request with neither `Sec-Fetch-Site` nor `Origin` is not a browser form, so it passes, with or without `allowed_origins`. On the auth routes the body rule below still applies to it. diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index 45bb2af..e5b38db 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -968,6 +968,11 @@ def _check_origin(self, method: str, headers: Headers) -> _Reply | None: return None blocked = _error(403, "cross_site_request_blocked") + # An opaque origin (a sandboxed frame, a data: URL) is refused before + # anything else, whatever the fetch metadata says. + origin = headers.get("origin") + if origin is not None and origin.strip().lower() == "null": + return blocked site = (headers.get("sec-fetch-site") or "").lower() if site == "cross-site": return blocked @@ -976,9 +981,6 @@ def _check_origin(self, method: str, headers: Headers) -> _Reply | None: # same-site also covers a sibling subdomain, and a request with no fetch # metadata could come from anywhere, so the Origin is what decides. With # neither header it is no browser form, and the body gate still applies. - origin = headers.get("origin") - if origin == "null": - return blocked if origin is None and not site: return None if self._config.allowed_origins is None: From 0b512cf7dc046cd48984ca3d583d53509e4141be Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:09:08 -0500 Subject: [PATCH 11/22] fix: require PyJWT 2.10.1 or later PyJWT 2.10.0 matches a string issuer as a substring (CVE-2024-53861, GHSA-75c5-xw7c-p5pm), and JwksCache.verify relies on PyJWT for the iss check. The lockfile already pins 2.15.1, but an install that resolves on its own could land on 2.10.0. Refs #23 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 61dce83..444b17f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ # maintained continuation of httpx (Pydantic), which Starlette also moved to. "httpx2>=2.13,<3", # HS256 cookies and service tokens, RS256 verification against the API's JWKS. - "PyJWT[crypto]>=2.10,<3", + "PyJWT[crypto]>=2.10.1,<3", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 736e199..550c6ad 100644 --- a/uv.lock +++ b/uv.lock @@ -924,7 +924,7 @@ requires-dist = [ { name = "django", marker = "extra == 'django'", specifier = ">=5.2" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115" }, { name = "httpx2", specifier = ">=2.13,<3" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1,<3" }, ] provides-extras = ["fastapi", "django"] From eb9f2876c51ccfb986711dffca88cba87479f6f3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:10:14 -0500 Subject: [PATCH 12/22] test: reproduce a redirect-following caller client carrying requests off-origin An http_client passed with follow_redirects=True follows an upstream redirect: the adapter's upstream call carries x-seamless-service-token to the redirect target, the console's own redirect check never runs, and the JWKS fetch accepts a key set from another host. Refs #26 --- tests/test_adapter.py | 22 ++++++++++++++++++++++ tests/test_console.py | 16 ++++++++++++++++ tests/test_jwks.py | 17 +++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 2d30d21..1fef367 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -1073,3 +1073,25 @@ def test_a_client_without_a_timeout_gets_one(api: FakeApi) -> None: assert adapter._timeout == {"timeout": 15.0} # The test client keeps httpx2's default timeout, so it is left alone. assert api.adapter()._timeout == {} + + +def test_a_caller_client_that_follows_redirects_does_not_carry_the_service_token_away( + api: FakeApi, +) -> None: + hosts: list[str] = [] + + def handle(request: httpx2.Request) -> httpx2.Response: + hosts.append(request.url.host) + return api.handle(request) + + api.on( + "POST", + "/login", + lambda _r: httpx2.Response(307, headers={"location": "http://evil.test/steal"}), + ) + client = httpx2.Client(transport=httpx2.MockTransport(handle), follow_redirects=True) + + r = call(api.adapter(http_client=client), "POST", "/login", body="{}") + + assert "evil.test" not in hosts + assert r.status == 307 diff --git a/tests/test_console.py b/tests/test_console.py index 2269b6f..0cade8c 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -151,3 +151,19 @@ def test_the_fastapi_console_router(api: FakeApi) -> None: assert client.get("/console/%2e%2e/jwks.json").status_code == 400 assert client.post("/console/x").status_code == 405 assert all("cookie" not in c.headers for c in api.calls) + + +def test_a_caller_client_that_follows_redirects_keeps_the_console_inside_itself( + api: FakeApi, +) -> None: + api.on( + "GET", + "/console/escape", + lambda _r: httpx2.Response(302, headers={"location": "/.well-known/jwks.json"}), + ) + client = httpx2.Client(transport=httpx2.MockTransport(api.handle), follow_redirects=True) + + r = read(api.adapter(http_client=client).console("GET", "/escape")) + + assert r.status == 502 + assert not api.calls_to("GET", "/.well-known/jwks.json") diff --git a/tests/test_jwks.py b/tests/test_jwks.py index b28b1e4..ce06e28 100644 --- a/tests/test_jwks.py +++ b/tests/test_jwks.py @@ -93,3 +93,20 @@ def failing(_request: httpx2.Request) -> httpx2.Response: cache.key("k1") assert fetches == 1, "a second miss inside the backoff does not fetch again" assert cache._fetching is None + + +def test_a_caller_client_that_follows_redirects_does_not_fetch_keys_elsewhere() -> None: + hosts: list[str] = [] + + def handle(request: httpx2.Request) -> httpx2.Response: + hosts.append(request.url.host) + if request.url.host == "evil.test": + return httpx2.Response(200, json=JWKS) + return httpx2.Response(302, headers={"location": "http://evil.test/jwks.json"}) + + client = httpx2.Client(transport=httpx2.MockTransport(handle), follow_redirects=True) + cache = JwksCache(JWKS_URL, client) + + with pytest.raises(TokenError): + cache.verify(api_token("u1", "access"), API_URL, API_URL) + assert "evil.test" not in hosts From 441b89099b5df224101bdbe46041f3ae3361261f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:10:32 -0500 Subject: [PATCH 13/22] fix: never follow redirects, even on a caller-supplied http_client Every upstream send, the console proxy, and the JWKS, manifest and discovery fetches now pass follow_redirects=False. A client built with follow_redirects=True no longer carries x-seamless-service-token to a redirect target, no longer skips the console's own redirect check, and no longer takes a key set from another host. Refs #26 --- README.md | 2 +- src/seamless_auth/_adapter.py | 5 +++-- src/seamless_auth/_console.py | 2 +- src/seamless_auth/_jwks.py | 4 +++- src/seamless_auth/_manifest.py | 4 +++- src/seamless_auth/_resource_server.py | 2 +- 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 799d168..80bf296 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ form post answers 415 too. `POST /auth/refresh`, `DELETE /auth/logout` and | `deliver_account_notices` | `False` | `deliver` also sends account notices to the owner (below) | | `resolve_client_ip` | the connecting peer | The end user's address. See below | | `disable_manifest_fetch` | `False` | Use only the manifest bundled with this version | -| `http_client` | 15 second timeout, no redirects | Outbound `httpx2.Client` | +| `http_client` | 15 second timeout | Outbound `httpx2.Client`. Redirects are never followed, whatever it is set to | The adapter refuses to start with a known placeholder for either secret, such as the starters' `dev-only-cookie-signing-key-replace-me`, `GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV`, or anything diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index e5b38db..81581e0 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -273,7 +273,8 @@ def __init__( pre_auth_cookie_name=pre_auth_cookie_name, ) # A client that follows redirects would carry the service token to wherever - # the auth API pointed it. + # the auth API pointed it, so every request also says not to: a supplied + # client may have been built to follow them. self._client = http_client or httpx2.Client( timeout=UPSTREAM_TIMEOUT, follow_redirects=False ) @@ -429,7 +430,7 @@ def _call( request = self._client.build_request( method, url, headers=headers, content=content, **self._timeout ) - return self._client.send(request, stream=stream) + return self._client.send(request, stream=stream, follow_redirects=False) except (httpx2.HTTPError, httpx2.InvalidURL, UnicodeError, ValueError) as err: # The type only: the text of these holds the URL, and a path or query # can hold a magic-link token or an OAuth code. diff --git a/src/seamless_auth/_console.py b/src/seamless_auth/_console.py index f5c88f0..3d9130b 100644 --- a/src/seamless_auth/_console.py +++ b/src/seamless_auth/_console.py @@ -107,7 +107,7 @@ def handle(self, method: str, subpath: str, query: str = "") -> AuthResponse: try: for _ in range(MAX_REDIRECTS + 1): request = client.build_request(method, url, timeout=UPSTREAM_TIMEOUT) - response = client.send(request, stream=True) + response = client.send(request, stream=True, follow_redirects=False) if not response.is_redirect: break follow = response.next_request diff --git a/src/seamless_auth/_jwks.py b/src/seamless_auth/_jwks.py index 188f1ad..9f35e17 100644 --- a/src/seamless_auth/_jwks.py +++ b/src/seamless_auth/_jwks.py @@ -109,7 +109,9 @@ def _refresh(self, kid: str) -> Jwk: return keys[kid] def _fetch(self) -> dict[str, Jwk]: - with self._client.stream("GET", self._url, timeout=FETCH_TIMEOUT) as res: + with self._client.stream( + "GET", self._url, timeout=FETCH_TIMEOUT, follow_redirects=False + ) as res: if res.status_code != 200: raise TokenError(f"HTTP {res.status_code}") body = read_limited(res, MAX_JWKS_BYTES) diff --git a/src/seamless_auth/_manifest.py b/src/seamless_auth/_manifest.py index 246a093..d40533d 100644 --- a/src/seamless_auth/_manifest.py +++ b/src/seamless_auth/_manifest.py @@ -243,7 +243,9 @@ def get(self) -> Manifest: def _load(self) -> Manifest: headers = {"accept": "application/json"} - with self._client.stream("GET", self._url, headers=headers, timeout=FETCH_TIMEOUT) as res: + with self._client.stream( + "GET", self._url, headers=headers, timeout=FETCH_TIMEOUT, follow_redirects=False + ) as res: if res.status_code != 200: raise ManifestError(f"HTTP {res.status_code}") return Manifest.parse(read_limited(res, MAX_MANIFEST_BYTES)) diff --git a/src/seamless_auth/_resource_server.py b/src/seamless_auth/_resource_server.py index 9918df2..0640179 100644 --- a/src/seamless_auth/_resource_server.py +++ b/src/seamless_auth/_resource_server.py @@ -181,7 +181,7 @@ def _keys(self) -> JwksCache: def _discover(self) -> str: url = self.issuer.rstrip("/") + DISCOVERY_PATH - with self._client.stream("GET", url, timeout=FETCH_TIMEOUT) as res: + with self._client.stream("GET", url, timeout=FETCH_TIMEOUT, follow_redirects=False) as res: if res.status_code != 200: raise TokenError(f"HTTP {res.status_code}") body = read_limited(res, MAX_DISCOVERY_BYTES) From a99a29aaf40a8a5dbd0bddae714f41977e971afc Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:10:37 -0500 Subject: [PATCH 14/22] test: reproduce logout leaving the session live when the access cookie is unreadable With an access cookie that fails verification and a valid refresh cookie, logout clears the cookies and answers 401 without calling the auth API, so the session and its refresh token stay valid there. Refs #26 --- tests/test_adapter.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 1fef367..7d337b1 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -1095,3 +1095,15 @@ def handle(request: httpx2.Request) -> httpx2.Response: assert "evil.test" not in hosts assert r.status == 307 + + +def test_logout_with_an_unreadable_access_cookie_still_ends_the_session(api: FakeApi) -> None: + api.on("POST", "/refresh", respond(200, session())) + api.on("DELETE", "/logout", respond(200, {"message": "Success"})) + + r = call(api.adapter(), "DELETE", "/logout", cookie=f"{access()}x; {REFRESH}") + + assert len(api.calls_to("DELETE", "/logout")) == 1 + assert r.status == 204 + for name in ("seamless-access", "seamless-ephemeral", "seamless-refresh"): + assert r.cleared(name), name From e88dcaffec82af514cc7d379b8003044fcfda9ec Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:10:56 -0500 Subject: [PATCH 15/22] fix: end the session at the auth API when logout finds an unreadable access cookie Logout used to clear the cookies and answer 401 without calling the auth API when the access cookie failed verification, leaving the session and its refresh token valid there. It now spends the refresh cookie for an access token, best effort, and calls /logout with it. The API's /logout accepts an access token only, so sending the refresh token itself would be refused and revoke nothing. Refs #26 --- src/seamless_auth/_adapter.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index 81581e0..a519386 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -497,6 +497,13 @@ def _credential_for( return _error(401, f"Invalid or expired {name} cookie") return _error(401, f'Missing required cookie "{name}"') + def _unreadable_access_cookie(self, headers: Headers) -> bool: + name = self._config.access_cookie_name + return ( + raw_cookie(headers, name) is not None + and read_cookie(self._config, headers, name, cookie_kind("access")) is None + ) + def _optional_pre_auth(self, headers: Headers, bearer: bool) -> str | None: if bearer: token = _bearer_token(headers) @@ -841,8 +848,16 @@ def _logout_route( ] credential = self._credential_for(headers, forwarded, "access", bearer) if isinstance(credential, _Reply): - credential.clear = clears - return credential + if not bearer and self._unreadable_access_cookie(headers): + # Credential resolution never refreshes past an access cookie that + # fails verification, so nothing told the auth API. Its /logout takes + # an access token only, so the refresh cookie is spent for one. + refreshed = self._silent_refresh(headers, forwarded) + if isinstance(refreshed, tuple): + credential = (f"Bearer {str_claim(refreshed[0], 'token')}", []) + if isinstance(credential, _Reply): + credential.clear = clears + return credential try: res = self._call( forwarded, From e26c3bae7a91fbffd7e94c98a3f2e75d788e4c5f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:11:01 -0500 Subject: [PATCH 16/22] test: reproduce the adapter verifying with a key published for another use JwksCache.verify accepts an RS256 token under a key the JWKS marks use=enc or alg=RS512. ResourceServer already refuses both. Refs #26 --- tests/test_jwks.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_jwks.py b/tests/test_jwks.py index ce06e28..10e9e85 100644 --- a/tests/test_jwks.py +++ b/tests/test_jwks.py @@ -110,3 +110,15 @@ def handle(request: httpx2.Request) -> httpx2.Response: with pytest.raises(TokenError): cache.verify(api_token("u1", "access"), API_URL, API_URL) assert "evil.test" not in hosts + + +@pytest.mark.parametrize(("member", "value"), [("use", "enc"), ("alg", "RS512")]) +def test_a_key_published_for_another_use_does_not_verify(member: str, value: str) -> None: + jwks = {"keys": [{**JWKS["keys"][0], member: value}]} + client = httpx2.Client( + transport=httpx2.MockTransport(lambda _r: httpx2.Response(200, json=jwks)) + ) + cache = JwksCache(JWKS_URL, client) + + with pytest.raises(TokenError): + cache.verify(api_token("u1", "access"), API_URL, API_URL) From 59a75da68806f7ddcd6ce7676d913dac0e167ae8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:11:13 -0500 Subject: [PATCH 17/22] fix: refuse a JWKS key published for another use or algorithm JwksCache.verify now refuses a key whose JWK says use other than sig or alg other than RS256, as ResourceServer already did. Both share one check. Refs #26 --- src/seamless_auth/_jwks.py | 14 ++++++++++++-- src/seamless_auth/_resource_server.py | 7 +------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/seamless_auth/_jwks.py b/src/seamless_auth/_jwks.py index 9f35e17..a6851ea 100644 --- a/src/seamless_auth/_jwks.py +++ b/src/seamless_auth/_jwks.py @@ -41,6 +41,15 @@ class Jwk: key: RSAPublicKey params: dict[str, Any] + def check_rs256_signing(self) -> None: + """Refuses a key its JWK publishes for another use or algorithm.""" + if self.params.get("kty") != "RSA": + raise TokenError("not an RSA key") + if "use" in self.params and self.params["use"] != "sig": + raise TokenError("key is not for signatures") + if "alg" in self.params and self.params["alg"] != "RS256": + raise TokenError("key is for another algorithm") + class JwksCache: """The key set at ``url``, fetched on first use and kept fresh.""" @@ -142,11 +151,12 @@ def verify(self, token: str, issuer: str, audience: str) -> Claims: if header.get("alg") != "RS256": raise TokenError("unexpected algorithm") kid = header.get("kid") - key = self.key(kid if isinstance(kid, str) else "").key + jwk = self.key(kid if isinstance(kid, str) else "") + jwk.check_rs256_signing() try: claims: Claims = jwt.decode( token, - key, + jwk.key, algorithms=["RS256"], issuer=issuer, audience=audience, diff --git a/src/seamless_auth/_resource_server.py b/src/seamless_auth/_resource_server.py index 0640179..933d5d7 100644 --- a/src/seamless_auth/_resource_server.py +++ b/src/seamless_auth/_resource_server.py @@ -212,12 +212,7 @@ def _verify(self, token: str, request: BearerRequest | None) -> VerifiedUser: raise TokenError("critical header parameters are not supported") jwk = self._keys().key(kid) - if jwk.params.get("kty") != "RSA": - raise TokenError("not an RSA key") - if "use" in jwk.params and jwk.params["use"] != "sig": - raise TokenError("key is not for signatures") - if "alg" in jwk.params and jwk.params["alg"] != "RS256": - raise TokenError("key is for another algorithm") + jwk.check_rs256_signing() signing_input = f"{segments[0]}.{segments[1]}".encode("ascii") try: signed = _RS256.verify(signing_input, jwk.key, _b64decode(segments[2])) From a9edaccd647d1e669569c6c38bd129b1f6d74585 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:11:18 -0500 Subject: [PATCH 18/22] test: reproduce the FastAPI confirm hook URL following the Host header RequireBearer hands the confirm hook str(request.url), which Starlette builds from the request's Host header, so a DPoP htu check compares against a host the client chose. Refs #26 --- tests/test_verify_only_frameworks.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_verify_only_frameworks.py b/tests/test_verify_only_frameworks.py index 38e1d85..382dbdd 100644 --- a/tests/test_verify_only_frameworks.py +++ b/tests/test_verify_only_frameworks.py @@ -25,7 +25,7 @@ from fastapi import Depends, FastAPI from fastapi.testclient import TestClient -from seamless_auth import VerifiedUser, require_scope +from seamless_auth import BearerRequest, VerifiedUser, require_scope from seamless_auth.django import _reset_resource_server from seamless_auth.fastapi import RequireBearer, add_bearer_error_handler @@ -170,3 +170,27 @@ def x(user: VerifiedUser = Depends(RequireBearer(server))) -> dict[str, str]: # r = TestClient(app).get("/x", headers=[("authorization", auth), ("authorization", auth)]) assert r.status_code == 401 assert r.json() == {"error": "missing_token"} + + +def test_fastapi_confirm_hook_url_does_not_come_from_the_host_header() -> None: + seen: list[str] = [] + + def confirm(cnf: Any, request: BearerRequest | None) -> bool: + assert request is not None + seen.append(request.url) + return True + + server = Provider().server(confirm=confirm) + app = FastAPI() + + @app.get("/orders") + def orders(user: VerifiedUser = Depends(RequireBearer(server))) -> dict[str, str]: # noqa: B008 + return {"sub": user.sub} + + r = TestClient(app, base_url="https://api.example").get( + "/orders?page=2", + headers={"authorization": f"Bearer {VECTORS['tokens']['cnf_bound']}", "host": "evil.test"}, + ) + + assert r.status_code == 200 + assert seen == ["https://api.example/orders?page=2"] From 0b75cedf3cb18c3d34e40c4d05df5c747bc076f6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:11:52 -0500 Subject: [PATCH 19/22] fix: let RequireBearer take the API's public origin for the confirm hook URL RequireBearer(server, base_url=...) builds the confirm hook's URL from the given origin and the request's path and query, so a DPoP htu check no longer compares against the client's Host header. Without base_url the URL is Starlette's as before, and the README now says to front the app with TrustedHostMiddleware and trusted proxy headers. Refs #26 --- README.md | 7 +++++++ src/seamless_auth/fastapi.py | 31 +++++++++++++++++++++++++--- tests/test_verify_only_frameworks.py | 14 +++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 80bf296..6e08c7e 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,13 @@ def confirm(cnf, request): rs = ResourceServer(issuer=..., audience=..., confirm=confirm) ``` +The URL is built from the request, and its host from a `Host` header the client chooses. Under +FastAPI, pass the API's public origin so a proof's `htu` is checked against it instead: +`RequireBearer(rs, base_url="https://api.example.com")`. Without it, put Starlette's +`TrustedHostMiddleware` in front of the app, and behind a proxy trust its forwarded headers only +from that proxy. Under Django the URL comes from `build_absolute_uri()`, which `ALLOWED_HOSTS` +already guards. + ## How it runs The adapter is synchronous and thread-safe: one `Adapter` serves the whole process. Under FastAPI diff --git a/src/seamless_auth/fastapi.py b/src/seamless_auth/fastapi.py index 520e569..44cfbac 100644 --- a/src/seamless_auth/fastapi.py +++ b/src/seamless_auth/fastapi.py @@ -37,7 +37,7 @@ def orders(user: VerifiedUser = Depends(RequireBearer(rs, require_scope("orders: from __future__ import annotations from collections.abc import Iterable -from urllib.parse import quote +from urllib.parse import quote, urlsplit from fastapi import APIRouter, FastAPI, HTTPException, Request from starlette.background import BackgroundTask @@ -184,17 +184,42 @@ class RequireBearer: """A dependency that verifies the request's OpenID Provider access token, runs ``guards`` in order, and returns the :class:`~seamless_auth.VerifiedUser`. + ``base_url`` is this API's public origin, such as ``https://api.example.com``. + The ``confirm`` hook's URL (a DPoP proof's ``htu``) takes its scheme and host + from it rather than from the request, whose ``Host`` header the client chooses. + Without it the URL is Starlette's, so put ``TrustedHostMiddleware`` (and, behind + a proxy, trusted forwarded headers) in front of the app. + A plain function, so FastAPI runs it on its threadpool: the first request may fetch the provider's discovery document and key set. """ - def __init__(self, server: ResourceServer, *guards: Guard) -> None: + def __init__(self, server: ResourceServer, *guards: Guard, base_url: str | None = None) -> None: self._server = server self._guards = guards + self._origin: tuple[str, str] | None = None + if base_url is not None: + parts = urlsplit(base_url) if isinstance(base_url, str) else None + if ( + parts is None + or parts.scheme not in ("http", "https") + or not parts.netloc + or parts.path not in ("", "/") + or parts.query + or parts.fragment + ): + raise ValueError("seamless-auth: base_url must be an http(s) origin") + self._origin = (parts.scheme, parts.netloc) + + def _url(self, request: Request) -> str: + if self._origin is None: + return str(request.url) + scheme, netloc = self._origin + return str(request.url.replace(scheme=scheme, netloc=netloc)) def __call__(self, request: Request) -> VerifiedUser: values = request.headers.getlist("authorization") - bearer = BearerRequest(request.method, _headers(request), lambda: str(request.url)) + bearer = BearerRequest(request.method, _headers(request), lambda: self._url(request)) try: # More than one Authorization header is not one token. user = self._server.authenticate(values[0] if len(values) == 1 else None, bearer) diff --git a/tests/test_verify_only_frameworks.py b/tests/test_verify_only_frameworks.py index 382dbdd..178350d 100644 --- a/tests/test_verify_only_frameworks.py +++ b/tests/test_verify_only_frameworks.py @@ -183,14 +183,24 @@ def confirm(cnf: Any, request: BearerRequest | None) -> bool: server = Provider().server(confirm=confirm) app = FastAPI() + bearer = RequireBearer(server, base_url="https://api.example") + @app.get("/orders") - def orders(user: VerifiedUser = Depends(RequireBearer(server))) -> dict[str, str]: # noqa: B008 + def orders(user: VerifiedUser = Depends(bearer)) -> dict[str, str]: # noqa: B008 return {"sub": user.sub} - r = TestClient(app, base_url="https://api.example").get( + r = TestClient(app, base_url="http://evil.test").get( "/orders?page=2", headers={"authorization": f"Bearer {VECTORS['tokens']['cnf_bound']}", "host": "evil.test"}, ) assert r.status_code == 200 assert seen == ["https://api.example/orders?page=2"] + + +@pytest.mark.parametrize( + "base_url", ["api.example", "ftp://api.example", "https://api.example/v1", "https://"] +) +def test_fastapi_base_url_must_be_an_origin(base_url: str) -> None: + with pytest.raises(ValueError, match="base_url"): + RequireBearer(Provider().server(), base_url=base_url) From 648eefc20849ab71afbd918f260c182e0e1acc68 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 3 Nov 2026 10:56:51 -0500 Subject: [PATCH 20/22] ci: hold conformance to the content-type gate contract --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b57d93..96824e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,9 @@ jobs: - name: Conformance env: SEAMLESS_API_DIR: ${{ github.workspace }}/seamless-auth-api + # This adapter ships the content-type gate, so hold it to the pending + # contract (seamless-cli verify/harness/lib/pendingContracts.ts). + SEAMLESS_VERIFY_CONTRACTS: content-type-gate run: node seamless-cli/dist/index.js verify --adapter-url=http://localhost:8080 - name: Reference app log if: always() From 22ab72d58dac489737379568e309e4c35f53afb1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 3 Nov 2026 11:38:53 -0500 Subject: [PATCH 21/22] test: pin refresh sharing to in-flight for bearer and a 5 second window for cookies A bearer caller presenting a refresh token after its rotation finished is handed the kept result today instead of reaching the auth API, whose reuse detection would revoke the family. Cookie replays inside the window keep sharing the rotation, and replays after it reach the API. Refs fells-code/seamless-auth-api#558 --- tests/test_adapter.py | 113 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 7d337b1..d35d1ad 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -221,19 +221,118 @@ def test_post_refresh_rotates_both_cookies_and_returns_no_tokens(api: FakeApi) - assert "seamless-access" in r.cookies and "seamless-refresh" in r.cookies -def test_bearer_refresh_is_shared_for_one_refresh_token(api: FakeApi) -> None: +BEARER_REFRESH = {"x-seamless-auth-transport": "bearer", "authorization": "Bearer r1"} + + +def _start(n: int, run: Any) -> tuple[list[threading.Thread], list[Any]]: + results: list[Any] = [None] * n + + def one(i: int) -> None: + results[i] = run() + + threads = [threading.Thread(target=one, args=(i,)) for i in range(n)] + for t in threads: + t.start() + return threads, results + + +def _gated_refresh(api: FakeApi) -> tuple[threading.Event, threading.Event]: + started = threading.Event() + gate = threading.Event() + + def refresh(_req: httpx2.Request) -> httpx2.Response: + started.set() + gate.wait(2) + return httpx2.Response(200, json=session()) + + api.on("POST", "/refresh", refresh) + return started, gate + + +def test_bearer_refresh_in_flight_is_shared_for_one_refresh_token(api: FakeApi) -> None: + started, gate = _gated_refresh(api) + adapter = api.adapter() + + first: list[Any] = [] + leader = threading.Thread( + target=lambda: first.append(call(adapter, "POST", "/refresh", headers=BEARER_REFRESH)) + ) + leader.start() + assert started.wait(2) + threads, results = _start(2, lambda: call(adapter, "POST", "/refresh", headers=BEARER_REFRESH)) + gate.set() + leader.join() + for t in threads: + t.join() + + assert first[0].status == 200 + assert first[0].body["refreshToken"] == "refresh-u1" + for r in results: + assert r.body == first[0].body + assert len(api.calls_to("POST", "/refresh")) == 1 + assert not first[0].cookies + + +def test_a_bearer_replay_after_its_rotation_finished_reaches_the_api(api: FakeApi) -> None: api.on("POST", "/refresh", respond(200, session())) adapter = api.adapter() - headers = {"x-seamless-auth-transport": "bearer", "authorization": "Bearer r1"} - first = call(adapter, "POST", "/refresh", headers=headers) - second = call(adapter, "POST", "/refresh", headers=headers) + first = call(adapter, "POST", "/refresh", headers=BEARER_REFRESH) + api.on("POST", "/refresh", respond(401, {"error": "refresh_token_reused"})) + replay = call(adapter, "POST", "/refresh", headers=BEARER_REFRESH) assert first.status == 200 - assert first.body["refreshToken"] == "refresh-u1" - assert second.body == first.body + assert replay.status == 401 + assert replay.body == {"error": "refresh_token_reused"} + assert len(api.calls_to("POST", "/refresh")) == 2 + assert not adapter._refreshes._flights + + +def test_a_cookie_replay_inside_the_window_gets_the_same_rotation(api: FakeApi) -> None: + api.on("POST", "/refresh", respond(200, session())) + adapter = api.adapter() + + first = call(adapter, "POST", "/refresh", cookie=REFRESH) + replay = call(adapter, "POST", "/refresh", cookie=REFRESH) + restored = call(adapter, "GET", "/users/me", cookie=REFRESH) + + assert first.status == 200 and replay.status == 200 + for name in ("seamless-access", "seamless-refresh"): + assert replay.cookies[name].value == first.cookies[name].value, name + assert restored.cookies[name].value == first.cookies[name].value, name assert len(api.calls_to("POST", "/refresh")) == 1 - assert not first.cookies + + +def test_a_cookie_replay_after_the_window_reaches_the_api( + api: FakeApi, monkeypatch: pytest.MonkeyPatch +) -> None: + clock = [1000.0] + monkeypatch.setattr("seamless_auth._refresh.time.monotonic", lambda: clock[0]) + api.on("POST", "/refresh", respond(200, session())) + adapter = api.adapter() + + first = call(adapter, "POST", "/refresh", cookie=REFRESH) + clock[0] += 5.5 + api.on("POST", "/refresh", respond(401, {"error": "refresh_token_reused"})) + replay = call(adapter, "POST", "/refresh", cookie=REFRESH) + + assert first.status == 200 + assert replay.status == 401 + assert replay.cleared("seamless-access") and replay.cleared("seamless-refresh") + assert len(api.calls_to("POST", "/refresh")) == 2 + assert not adapter._refreshes._flights + + +def test_a_bearer_replay_inside_the_cookie_window_still_reaches_the_api(api: FakeApi) -> None: + api.on("POST", "/refresh", respond(200, session())) + adapter = api.adapter() + + cookie = call(adapter, "POST", "/refresh", cookie=REFRESH) + first = call(adapter, "POST", "/refresh", headers=BEARER_REFRESH) + replay = call(adapter, "POST", "/refresh", headers=BEARER_REFRESH) + + assert cookie.status == 200 and first.status == 200 and replay.status == 200 + assert len(api.calls_to("POST", "/refresh")) == 3 def test_bearer_refresh_without_a_token_is_401(api: FakeApi) -> None: From 8f5607ec7c9ca7b402069c7d3d6695b5eacc746f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 3 Nov 2026 11:39:29 -0500 Subject: [PATCH 22/22] fix: keep a completed refresh for cookie transport for five seconds Every caller presenting one refresh token while its rotation runs still shares that rotation, in either transport. Once it finishes, only cookie transport keeps the result for the 5 second window, so several tabs holding one refresh cookie all succeed. A bearer caller presenting a spent refresh token now reaches the auth API, whose reuse detection revokes the family. Failed rotations are never kept, and finished entries are pruned. Refs fells-code/seamless-auth-api#558 --- AGENTS.md | 4 ++-- README.md | 7 +++++-- src/seamless_auth/_adapter.py | 6 +++--- src/seamless_auth/_refresh.py | 23 ++++++++++++++--------- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ef21533..5112c35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,8 @@ current Django, so both are covered. Do not use language or standard library fea routes, credential resolution (with silent refresh), session verification, refresh, logout, the guard, the cross-site check. - `src/seamless_auth/_console.py`: the admin dashboard proxy (`Adapter.console`). -- `src/seamless_auth/_refresh.py`: refresh sharing (one result per refresh token for 5 seconds, - across threads). +- `src/seamless_auth/_refresh.py`: refresh sharing across threads (one rotation per refresh token + while it runs, and for cookie transport only, its result for 5 seconds after). - `src/seamless_auth/_manifest.py`: parsing, matching, the live and bundled manifest. - `src/seamless_auth/_cookies.py`, `_jwt.py`, `_jwks.py`: HS256 cookies and service tokens, RS256 against the API's JWKS. diff --git a/README.md b/README.md index 6e08c7e..c04b608 100644 --- a/README.md +++ b/README.md @@ -518,8 +518,11 @@ already guards. The adapter is synchronous and thread-safe: one `Adapter` serves the whole process. Under FastAPI the bindings run it on Starlette's threadpool, so it never blocks the event loop. Under Django it -runs in the request thread, on WSGI or ASGI. Refresh sharing (one rotation per refresh token, -handed to every parallel request for 5 seconds) works across threads. +runs in the request thread, on WSGI or ASGI. Refresh sharing works across threads: requests that +present one refresh token while its rotation runs share that rotation, in either transport. For +cookie transport only, a request presenting the same refresh cookie within 5 seconds after gets +the same rotated session, so several tabs with one expired session all succeed. A bearer client +presenting a spent refresh token always reaches the auth API. ## The manifest diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index a519386..8108050 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -739,7 +739,7 @@ def _refresh_after_enrollment( return issued service = self._sign_service(sub, {"refreshToken": refresh_token}) outcome = self._refreshes.share( - key, lambda: self._rotate(forwarded, refresh_token, service) + key, lambda: self._rotate(forwarded, refresh_token, service), keep=True ) if outcome.session is None: log.debug("[seamless-auth] Refresh after enrollment failed") @@ -795,7 +795,7 @@ def _silent_refresh( service = self._sign_service(str_claim(payload, "sub"), {"refreshToken": refresh_token}) outcome = self._refreshes.share( - f"cookie:{raw}", lambda: self._rotate(forwarded, refresh_token, service) + f"cookie:{raw}", lambda: self._rotate(forwarded, refresh_token, service), keep=True ) if outcome.session is None: return outcome.error or f"refresh answered {outcome.status}" @@ -813,7 +813,7 @@ def _refresh_route(self, headers: Headers, forwarded: _Forwarded, bearer: bool) return _error(401, "refresh token required") service = self._proxy_authorization() outcome = self._refreshes.share( - f"bearer:{token}", lambda: self._rotate(forwarded, token, service) + f"bearer:{token}", lambda: self._rotate(forwarded, token, service), keep=False ) if outcome.session is None: if outcome.error is not None: diff --git a/src/seamless_auth/_refresh.py b/src/seamless_auth/_refresh.py index 7e4a25e..7a4b888 100644 --- a/src/seamless_auth/_refresh.py +++ b/src/seamless_auth/_refresh.py @@ -1,5 +1,5 @@ -"""Refresh sharing: one rotation per refresh token, handed to every caller -presenting it for a few seconds.""" +"""Refresh sharing: one rotation per refresh token at a time, and for cookie +transport, handed to later callers presenting it for a few seconds.""" from __future__ import annotations @@ -12,9 +12,10 @@ from ._jwt import Claims -# A refresh token presented again within this window gets the rotation it already +# A refresh cookie presented again within this window gets the rotation it already # got, so parallel requests from several tabs with one expired session all succeed -# instead of tripping the auth API's reuse detection. +# instead of tripping the auth API's reuse detection. Bearer callers never get it: a +# bearer client replaying a spent token goes to the API, which revokes the family. REUSE_WINDOW = 5.0 # How long a caller waits on another's rotation before giving up. FLIGHT_TIMEOUT = 30.0 @@ -43,10 +44,14 @@ def __init__(self) -> None: self._flights: dict[str, _Flight] = {} self._ids = itertools.count(1) - def share(self, key: str, rotate: Callable[[], RefreshOutcome]) -> RefreshOutcome: - """Runs ``rotate`` once per key at a time, and hands a successful outcome - to every caller with the same key for the reuse window afterwards. A - failure is not kept, so each later caller sees it from the auth API itself.""" + def share( + self, key: str, rotate: Callable[[], RefreshOutcome], *, keep: bool + ) -> RefreshOutcome: + """Runs ``rotate`` once per key at a time, handing its outcome to every + caller that arrives while it runs. With ``keep`` (cookie transport) a + successful outcome also goes to callers with the same key for the reuse + window afterwards. A failure is never kept, so each later caller sees it + from the auth API itself.""" with self._lock: now = time.monotonic() for stale in [ @@ -72,7 +77,7 @@ def share(self, key: str, rotate: Callable[[], RefreshOutcome]) -> RefreshOutcom finally: with self._lock: if self._flights.get(key) is flight: - if outcome.session is not None: + if keep and outcome.session is not None: flight.finished = time.monotonic() else: del self._flights[key]