Skip to content

fix: origin check whatever the SameSite setting, and no JWKS lock across the fetch - #33

Merged
Bccorb merged 23 commits into
mainfrom
fix/wave1-origin-and-jwks-lock
Nov 3, 2026
Merged

Bccorb merged 23 commits into
mainfrom
fix/wave1-origin-and-jwks-lock

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes two v0.21 security review findings (epic fells-code/seamless-auth-api#558). Each was reproduced on main first: 7c05a83 adds only the tests, and they fail there (1 JWKS case, 12 form post cases, plus the allowlist and guard cases).

Closes #24
Closes #25

JWKS lock (#24)

The cache lock now guards only cache state and is never held across the network fetch. A cached kid is served straight away when the key set is fresh, or while another caller is refetching. The refetch is single flight, and waiters are released in a finally, so a failed fetch cannot hang them.

Origin and content type (#25)

  • The origin check runs on every unsafe cookie request whatever the SameSite setting. cross-site and Origin: null are refused with 403.
  • With allowed_origins set, same-site requests and requests without fetch metadata must carry a listed Origin. allowed_origins=[] is now strict, and the default is None (not set).
  • Body-less unsafe requests to auth routes need Content-Type: application/json or an x-seamless-auth-transport header (any value), otherwise 415 unsupported_media_type. POST /refresh and DELETE /logout are exempt in v0.23 but still get the origin check, and a refused logout clears no cookies.

This is the cross-adapter contract recorded in the v0.23 plan (fells-code/fellscode#32). The Node, Go and Rust adapters get the same behavior.

Merge order

Ready: @seamless-auth/client 0.8.3 (fells-code/seamless-auth-js#234) and seamless-cli 0.27.2 (fells-code/seamless-cli#345), which send x-seamless-auth-transport on unsafe requests, are released. Conformance runs with SEAMLESS_VERIFY_CONTRACTS=content-type-gate, so the 415 case is enforced here.

Refresh sharing was revised on 2026-11-03: a completed rotation is kept for 5 seconds for cookie transport only (concurrent tabs keep their session), and bearer replays always reach the auth API.

Release notes

  • Cross-site unsafe cookie requests are refused under Lax and Strict too, not only None.
  • allowed_origins=[] blocks every same-site and no-metadata state change. The type is now tuple[str, ...] | None.
  • Body-less unsafe auth requests without a JSON content type or the transport header get 415.

Checks

ruff, ruff format, mypy (strict) clean. pytest: 568 passed. The conformance suite was not run locally (needs the Docker stack); CI runs it.

Also in this PR (adapter-specific findings)

Closes #23
Closes #26

Each was reproduced first (test commits eb9f287, a99a29a, e26c3ba, a9edacc fail on the branch before their fix).

  • Raise the PyJWT floor to 2.10.1 #23 PyJWT floor raised to 2.10.1 (CVE-2024-53861, issuer partial match). With 2.10.0 the adapter accepted iss="auth" for http://auth.test. uv.lock already resolved 2.15.1, so only self-resolving installs were exposed.
  • Harden redirects, logout revocation and JWK use checks #26 redirects: follow_redirects=False on every send (adapter upstream, console proxy, JWKS, manifest and ResourceServer discovery), even on a caller-supplied http_client.
  • Harden redirects, logout revocation and JWK use checks #26 logout: differs from the issue text. The API's /logout takes an access token and refresh tokens are opaque, so sending the refresh token would revoke nothing. With an unreadable access cookie and a working refresh cookie, logout now spends the refresh cookie through the existing silent refresh and calls /logout with the new access token (204). If that refresh fails, the old 401 and cookie clearing stay.
  • Harden redirects, logout revocation and JWK use checks #26 JWK checks: a key published with use other than sig or alg other than RS256 no longer verifies tokens. Jwk.check_rs256_signing() is shared by JwksCache.verify and ResourceServer.
  • Harden redirects, logout revocation and JWK use checks #26 FastAPI confirm hook URL: new optional RequireBearer(..., base_url="https://api.example.com") takes scheme and host from configuration instead of the Host header. Without it, the README directs adopters to TrustedHostMiddleware. Django is already covered by ALLOWED_HOSTS. This adds a keyword to the public API under a fix: commit.

Checks: ruff, ruff format, mypy strict, uv lock --check clean; pytest 592 passed.

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
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
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
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
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
…igin 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
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
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
Bccorb added 15 commits November 2, 2026 21:48
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
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
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
…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
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
…e 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
…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
…r use

JwksCache.verify accepts an RS256 token under a key the JWKS marks
use=enc or alg=RS512. ResourceServer already refuses both.

Refs #26
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
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
…ook 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
…ow 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
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
@Bccorb
Bccorb marked this pull request as ready for review November 3, 2026 18:18
@Bccorb
Bccorb merged commit 3f8e828 into main Nov 3, 2026
7 checks passed
@github-actions github-actions Bot mentioned this pull request Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant