Skip to content

fix: guard unsafe requests whatever the SameSite setting, and no refresh replay after rotation - #270

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

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

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) in core, Express, Fastify and Next.js. Each was reproduced on main first: 1f64072, 00468d9 and 09f599b add only tests, which fail there.

Closes #256
Closes #258

Origin and content type (#256)

  • The origin check runs on every unsafe auth request whatever the SameSite setting, for cookie and bearer transport. allowedOrigins (with [] strict) applies to same-site requests and to requests with no fetch metadata that carry an Origin. Origin: null is refused first, in every case. A request with neither Origin nor Sec-Fetch-Site passes the origin check.
  • New core checkContentType: a body-less unsafe auth request needs Content-Type: application/json or an x-seamless-auth-transport header (any value), otherwise 415 unsupported_media_type. POST /refresh, DELETE /logout and DELETE /logout/all are exempt in v0.23 and still get the origin check. Origin (403) runs before the gate (415), both before cookie refresh and routing. A refused logout clears nothing.
  • Fastify was affected too: it refused urlencoded and multipart but accepted text/plain and no content type. Its plugin now reads an empty JSON body as no body, and Express forwards an empty JSON body as no body rather than {}.
  • Express and Fastify requireAuth/requireRole now run the origin check on cookie sessions, matching Next.js, Go, Python and Rust. New allowedOrigins option on requireAuth. Guards get the origin check only, not the content-type gate.

Refresh replay (#258)

The completed-result cache in dedupeRefresh is gone. Only in-flight rotations are shared, for every transport. A refresh token presented after its rotation finished reaches the auth API, which treats it as reuse.

This is the cross-adapter contract recorded in the v0.23 plan (fells-code/fellscode#32), matching fells-code/seamless-auth-go#30, fells-code/seamless-auth-python#33 and fells-code/seamless-auth-rust#34.

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

  • Body-less unsafe auth requests without a JSON content type or the transport header get 415. Server-to-server or curl callers that POST with no content type are affected too.
  • Under Lax or Strict cookies, cross-site, Origin: null, and unlisted same-site unsafe requests get 403 cross_site_request_blocked, on the auth routes and on guarded adopter routes with a cookie session.
  • Two tabs presenting the same old refresh cookie a moment apart, rather than concurrently, can now trip reuse detection and sign the user out.

Checks

pnpm build clean. pnpm test: core 688, express 300, fastify 304, nextjs 334, all passing. The conformance suite runs in CI.

Also in this PR (adapter-specific findings)

Closes #257
Closes #259

Refs #260 (partly)

Bccorb added 14 commits November 2, 2026 21:28
A same-site or fetch-metadata-free form POST carrying a Lax access cookie reaches cookie-authenticated routes on Express, Fastify (text/plain or no content type) and Next.js, and checkOrigin ignores cross-site and allowedOrigins unless cookies are SameSite=None. These tests fail on main.

Refs #256
The origin check only ran for SameSite=None cookies, and a body-less form
POST reached cookie-authenticated routes because Express and Next.js
forward urlencoded and multipart as an empty body and Fastify accepts
text/plain and a missing content type.

checkOrigin now runs whatever the SameSite setting and enforces
allowedOrigins (an empty list included) for same-site and metadata-free
requests. A new checkContentType refuses a state-changing request to the
auth routes with 415 unsupported_media_type unless it declares
application/json or carries an x-seamless-auth-transport header, even with
no body. POST /refresh, DELETE /logout and DELETE /logout/all are exempt
for now because the current browser client sends them bare.

Fastify accepts an empty JSON body on the plugin's routes and Express no
longer reads one as {}, so a body-less JSON request forwards no body.

Refs #256
A second caller presenting the same refresh cookie, session token or bearer refresh token after a completed rotation gets the new session from the 5 second completed-result window, and the auth API is never called. These tests fail on main.

Refs #258
dedupeRefresh kept a completed rotation for 5 seconds and handed it to any
caller presenting the same refresh cookie, session token or bearer refresh
token, without calling the auth API. A stolen refresh token replayed in that
window got the new session, and the API's reuse detection never saw it.

Drop the completed-result window for every transport. Callers that race one
rotation still share it; a caller that arrives after it completed goes to
the auth API as a replay.

Refs #258
Express and Fastify requireAuth let a cross-site, unlisted same-site or null-Origin state change through on a cookie session, where the Next.js guard refuses it. checkOrigin also passes a literal null Origin when Sec-Fetch-Site is same-origin, same-site or none. These tests fail on the branch.

Refs #256
requireAuth in Express and Fastify never called checkOrigin, so a
cross-site, unlisted same-site or null-Origin state change on a cookie
session reached the adopter's route, where the Next.js guard and the Go,
Python and Rust adapters refuse it. Run the origin check on a cookie
session, before authentication, with a new allowedOrigins option. Bearer
requests are not checked and no content type is required.

checkOrigin now refuses a literal null Origin whatever Sec-Fetch-Site and
allowedOrigins say, instead of only when Sec-Fetch-Site is absent.

Refs #256
The Fastify and Next.js option tables gain the 415 gate, its exemptions and the null-Origin rule, matching the Express README. The Express and Fastify guard sections describe the origin check and allowedOrigins.

Refs #256
…ding the client IP

Probe the compiled trust proxy function with documentation addresses, as
the Fastify adapter does, so subnets covering every address or a function
that always trusts no longer let a caller choose x-seamless-client-ip.

Refs #257
… auth server tokens

The JWKS prefix was already kept by #267, so this does not reproduce on
the current branch. Against the pre-#267 URL resolution it fails: a token
signed with the host root key verifies.

Refs #259
A verified user now carries isClient, true when sub equals client_id, so
adopters can tell a machine acting for itself from a user before reading
id as a user id.

Refs #260
Adds shared vectors for isClient on verified tokens and for an auth guard
that refuses client credentials tokens with 403 user_required. Both are
additive: is_client sits beside each verify entry's user, and the new
requests live in their own user_required_requests list, so the Go, Python
and Rust copies keep passing until those adapters take them up.

Refs #260
requireAuth from createResourceServer (and the Next.js requirements, and
core authenticate) take allowClients. With false, a verified client
credentials token is refused with 403 user_required and no challenge,
the same shape as insufficient_role. Invalid tokens still get 401 first,
and the default keeps today's behavior.

Refs #260
…sh cookie

Browser tabs share one cookie jar, so a second tab can send the old refresh
cookie a moment after the first tab's rotation completed. That replay should
get the session the first tab got for 5 seconds, without calling the auth API.
After 5 seconds, or for a failed rotation, it goes to the auth API. A bearer
replay always goes to the auth API, even inside the 5 seconds.

The two cookie window tests fail on this branch.

Refs #258
Browser tabs share one refresh cookie, so a second tab can send the old
cookie just after the first tab's rotation completed. Sending it to the auth
API trips reuse detection and signs both tabs out, which breaks the multi-tab
conformance case.

Keep a successful cookie rotation for 5 seconds, keyed on a hash of the old
refresh cookie, and hand it to a caller presenting that cookie without calling
the auth API. Failed rotations are not kept, expired entries are pruned on
insert and the map is capped. Bearer and session token refreshes keep sharing
only a rotation in flight, so a late bearer replay still reaches the auth API.

Refs #258
@Bccorb
Bccorb marked this pull request as ready for review November 3, 2026 18:18
@Bccorb
Bccorb merged commit 662b6aa into main Nov 3, 2026
6 checks passed
@Bccorb
Bccorb deleted the fix/wave1-origin-and-refresh-replay branch November 3, 2026 19:01
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