Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7c05a83
test: reproduce cross-site form posts and JWKS lock contention
Bccorb Nov 3, 2026
8960e32
fix: do not hold the JWKS cache lock across the network fetch
Bccorb Nov 3, 2026
b55f8b3
fix: check Origin on unsafe methods whatever the SameSite setting
Bccorb Nov 3, 2026
400a4fe
test: pin the origin check on refresh and logout
Bccorb Nov 3, 2026
92de5ea
test: pin logout/all to the origin check and out of the body gate
Bccorb Nov 3, 2026
fe8cd2d
fix: let a request with neither fetch metadata nor Origin pass the or…
Bccorb Nov 3, 2026
09f8bff
test: reproduce bearer transport skipping the origin check
Bccorb Nov 3, 2026
9354803
fix: run the origin check on the auth routes for bearer transport too
Bccorb Nov 3, 2026
e207c29
test: reproduce Origin null passing with same-origin fetch metadata
Bccorb Nov 3, 2026
e033d54
fix: refuse Origin null before fetch metadata and the allowlist
Bccorb Nov 3, 2026
0b512cf
fix: require PyJWT 2.10.1 or later
Bccorb Nov 3, 2026
eb9f287
test: reproduce a redirect-following caller client carrying requests …
Bccorb Nov 3, 2026
441b890
fix: never follow redirects, even on a caller-supplied http_client
Bccorb Nov 3, 2026
a99a29a
test: reproduce logout leaving the session live when the access cooki…
Bccorb Nov 3, 2026
e88dcaf
fix: end the session at the auth API when logout finds an unreadable …
Bccorb Nov 3, 2026
e26c3ba
test: reproduce the adapter verifying with a key published for anothe…
Bccorb Nov 3, 2026
59a75da
fix: refuse a JWKS key published for another use or algorithm
Bccorb Nov 3, 2026
a9edacc
test: reproduce the FastAPI confirm hook URL following the Host header
Bccorb Nov 3, 2026
0b75ced
fix: let RequireBearer take the API's public origin for the confirm h…
Bccorb Nov 3, 2026
32f961e
Merge remote-tracking branch 'origin/main' into fix/wave1-origin-and-…
Bccorb Nov 3, 2026
648eefc
ci: hold conformance to the content-type gate contract
Bccorb Nov 3, 2026
22ab72d
test: pin refresh sharing to in-flight for bearer and a 5 second wind…
Bccorb Nov 3, 2026
8f5607e
fix: keep a completed refresh for cookie transport for five seconds
Bccorb Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 39 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,20 +134,27 @@ 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):

- `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-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.

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.
- `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. 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.
- `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 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.
Expand Down Expand Up @@ -200,7 +207,13 @@ 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`, `DELETE /auth/logout` and
`DELETE /auth/logout/all` are exempt, though they still get the cross-site check.

## Options

Expand All @@ -215,14 +228,14 @@ 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 |
| `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
Expand Down Expand Up @@ -494,12 +507,22 @@ 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
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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
98 changes: 59 additions & 39 deletions src/seamless_auth/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -265,14 +266,15 @@ 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,
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
)
Expand Down Expand Up @@ -327,12 +329,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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -496,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)
Expand Down Expand Up @@ -532,8 +540,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
Expand Down Expand Up @@ -726,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")
Expand Down Expand Up @@ -782,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}"
Expand All @@ -800,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:
Expand Down Expand Up @@ -835,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,
Expand Down Expand Up @@ -957,40 +978,39 @@ 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")

# An opaque origin (a sandboxed frame, a data: URL) is refused before
# anything else, whatever the fetch metadata says.
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:
return None
if origin == "null":
if origin is not None and origin.strip().lower() == "null":
return blocked
if not self._config.allowed_origins:
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. With
# neither header it is no browser form, and the body gate still applies.
if origin is None and not site:
return None
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)
# Checked so an unparseable Origin cannot match an unparseable allowlist entry.
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 ()
)


Expand Down
2 changes: 1 addition & 1 deletion src/seamless_auth/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading