Skip to content

fix: origin check whatever the SameSite setting, no JWKS lock across the fetch, no refresh replay - #34

Merged
Bccorb merged 18 commits into
mainfrom
fix/wave1-origin-jwks-refresh
Nov 3, 2026
Merged

Bccorb merged 18 commits into
mainfrom
fix/wave1-origin-jwks-refresh

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes three v0.21 security review findings (epic fells-code/seamless-auth-api#558). Each was reproduced on main first: 74d2262, 120a428 and a076584 add only tests, which fail there (5, 1 and 2 failures).

Closes #24
Closes #25
Closes #26

Origin and content type (#24)

  • The origin check runs on every unsafe method whatever the SameSite setting, on the auth routes and on require_auth cookie sessions.
  • .allowed_origins(empty) now means configured and strict.
  • Body-less unsafe manifest routes need Content-Type: application/json or an x-seamless-auth-transport header (any value; only bearer selects bearer transport), otherwise 415 unsupported_media_type. /refresh, /logout and /logout/all are exempt in v0.23 so current clients keep working, and still get the origin check. A refused logout clears nothing.

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

JWKS lock (#25)

The key set sits behind a short lock that is never held across a fetch. Refetches are single flight on a separate lock. A cached kid, fresh or stale, is served without waiting; an unknown kid waits for the fetch in flight and re-checks, so a burst of unknown kids costs one fetch. Discovery in verifier.rs was checked and is not affected: its lock is only held before any keys are cached.

Refresh replay (#26)

REUSE_WINDOW is gone. A rotation is shared only with callers that arrive while it is in flight. A refresh token presented after its rotation finished reaches the auth API, which treats it as reuse.

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.

Checks

cargo fmt --check, cargo clippy --all-targets -- -D warnings (default, --no-default-features, and native-tls), cargo test (117 + 1 doctest), and cargo +1.88 test (MSRV) pass. The conformance suite was not run locally; CI runs it.

Also in this PR

Closes #27

  • Null Origin (a191b6d, 5e725b2 tests; 02f6f5f, 9890782 fixes): Origin: null (trimmed, any case) is refused first on every unsafe request.
  • Keep rotated cookies on upstream errors and clear the custom pre-auth cookie on logout #27 (3021fbd test, 8f3933b fix): a manifest route that silently refreshes and then gets a session it cannot verify or store now returns its 502 with the rotated cookies, so the browser does not keep a spent refresh token. TrustedProxies reads ip:port, [v6]:port and bare [v6] hops. The pre-auth cookie part was already fixed in c6edea8; its existing test fails with that fix removed.

A body-less POST with Sec-Fetch-Site same-site or absent reaches a
cookie-authenticated route under SameSite=Lax, the origin check never
runs outside SameSite=None, and an empty configured allowlist is read
as not configured.

Refs #24
The cross-site check ran only with SameSite=None cookies, and the JSON
content type was required only when a body was present, so a body-less
form POST from a sibling subdomain or a browser without fetch metadata
reached cookie-authenticated routes.

- run the origin check on unsafe methods for every SameSite setting
- when allowed_origins is set, enforce it for same-site requests and
  requests without fetch metadata, and treat an empty list as strict
- require Content-Type: application/json or x-seamless-auth-transport
  on unsafe manifest routes even with no body, which forces a preflight

Refs #24
While a refetch triggered by an unknown kid is in flight, verifying a
token whose kid is cached and fresh waits for that fetch to finish.

Refs #25
Every verification took the cache lock, which a refetch held for up to
the 5 second fetch timeout, so a slow JWKS endpoint queued every
verification behind it, including tokens whose kid was cached and fresh.

The key set is now behind a lock held only to read or update it. A
cached kid is served without waiting on a fetch in progress, and the
refetch runs single-flight on its own lock, which an unknown kid waits
on so one fetch answers all of them.

Refs #25
After a rotation succeeds, presenting the same refresh token or cookie
again within 5 seconds gets the new session without the auth API being
called, so its reuse detection never sees the replay.

Refs #26
After a rotation succeeded, any caller presenting the same refresh token
or cookie within 5 seconds got the new session without the auth API
being called, so a stolen token replayed in that window took over the
session and the API's reuse detection never saw it.

A rotation is now shared only with callers that arrive while it is in
flight. Once it settles nothing is kept, and a later caller reaches the
auth API, which answers a replayed token as reuse.

Refs #26
POST /refresh, DELETE /logout and DELETE /logout/all pass body-less with
no content type or transport header, as the client SDKs send them, but
still get the origin check under every SameSite setting, and a refused
logout clears no cookies.

Refs #24
A Sec-Fetch-Site: same-site request carrying Origin: null passes when no
allowlist is configured, while every other unsafe request with a null
Origin is refused.

Refs #24
A null Origin is opaque or sandboxed, so it is refused on every unsafe
request, including a Sec-Fetch-Site: same-site one with no allowlist.

Refs #24
…tadata

A null Origin, in any case or with surrounding space, should be refused
on every unsafe request before Sec-Fetch-Site and the allowlist are
consulted. Sec-Fetch-Site same-origin or none lets it through.

Refs #24
A null Origin, trimmed and in any case, is now refused before
Sec-Fetch-Site and the allowlist are consulted, so same-origin or none
fetch metadata no longer lets it through. This folds the earlier
same-site special case into the single early check.

Refs #24
…d ported proxy hops

An access route that silently refreshes and then gets an unverifiable or
unstorable session back answers 502 without the rotated access and refresh
cookies. A trusted X-Forwarded-For hop written as ip:port or [v6]:port
resolves to no client address.

Logout clearing a pre-auth cookie with its own name is already covered by
logout_clears_a_pre_auth_cookie_with_its_own_name since c6edea8.

Refs #27
…ops with ports

A manifest route that silently refreshed the session and then got back a
session it could not verify or store answered 502 without the rotated
access and refresh cookies. The browser kept the spent refresh token and
was signed out by reuse detection on its next request. Both
invalid_upstream_session replies now carry the rotated cookies.

TrustedProxies read an X-Forwarded-For hop written as ip:port or
[v6]:port as unreadable and gave no client address, so the auth API
limited on the adapter's own address. Hops are now read past the port
and brackets; a hop whose port is not a port is still unreadable.

Refs #27
…window

A tab that presents a refresh cookie just after another tab rotated it must
get the same rotated session without a call to the auth API, for five
seconds. After that, and for bearer transport always, the replay reaches the
API. Expired entries must be pruned.

Refs #26
A tab that sends the old refresh cookie just after another tab rotated it now
gets the same rotated session without calling the auth API, for five seconds
after the rotation settles. The completed result is stored under the same lock
that ends the flight, so no caller can slip between the two and replay the
token. Only successful, verified rotations are kept, entries are pruned on
every access, and bearer transport never uses the window: a bearer replay
reaches the API, whose reuse detection revokes the family.

Refs #26
@Bccorb
Bccorb marked this pull request as ready for review November 3, 2026 18:18
@Bccorb
Bccorb merged commit 48b003e into main Nov 3, 2026
4 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