Repository navigation
fix: origin check whatever the SameSite setting, and no JWKS lock across the fetch - #33
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes two v0.21 security review findings (epic fells-code/seamless-auth-api#558). Each was reproduced on
mainfirst:7c05a83adds 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
kidis 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 afinally, so a failed fetch cannot hang them.Origin and content type (#25)
cross-siteandOrigin: nullare refused with 403.allowed_originsset,same-siterequests and requests without fetch metadata must carry a listed Origin.allowed_origins=[]is now strict, and the default isNone(not set).Content-Type: application/jsonor anx-seamless-auth-transportheader (any value), otherwise 415unsupported_media_type.POST /refreshandDELETE /logoutare 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/client0.8.3 (fells-code/seamless-auth-js#234) and seamless-cli 0.27.2 (fells-code/seamless-cli#345), which sendx-seamless-auth-transporton unsafe requests, are released. Conformance runs withSEAMLESS_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
allowed_origins=[]blocks every same-site and no-metadata state change. The type is nowtuple[str, ...] | None.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,a9edaccfail on the branch before their fix).iss="auth"forhttp://auth.test.uv.lockalready resolved 2.15.1, so only self-resolving installs were exposed.follow_redirects=Falseon every send (adapter upstream, console proxy, JWKS, manifest and ResourceServer discovery), even on a caller-suppliedhttp_client./logouttakes 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/logoutwith the new access token (204). If that refresh fails, the old 401 and cookie clearing stay.useother thansigoralgother thanRS256no longer verifies tokens.Jwk.check_rs256_signing()is shared byJwksCache.verifyandResourceServer.RequireBearer(..., base_url="https://api.example.com")takes scheme and host from configuration instead of the Host header. Without it, the README directs adopters toTrustedHostMiddleware. Django is already covered byALLOWED_HOSTS. This adds a keyword to the public API under afix:commit.Checks: ruff, ruff format, mypy strict,
uv lock --checkclean; pytest 592 passed.