Skip to content

fix: Origin allowlist with Sec-Fetch-Site, and verify the cookie's inner access token - #18

Merged
Bccorb merged 1 commit into
mainfrom
fix/origin-allowlist-inner-token
Oct 30, 2026
Merged

Bccorb merged 1 commit into
mainfrom
fix/origin-allowlist-inner-token

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Ports the v0.20 JS adapter hardening (fells-code/seamless-auth-server#234, issues #230 and #231 there) to the Python adapter. Both the FastAPI (RequireUser) and Django (require_auth) bindings call Adapter.authenticate_request, so the change lives once in the core (_adapter.py) and both get it. Each binding has a test for it.

Origin check (server#230)

Still only while cookies are SameSite=None and only for methods other than GET, HEAD and OPTIONS:

  • Sec-Fetch-Site: cross-site gives 403 cross_site_request_blocked, as before.
  • Sec-Fetch-Site: same-site: when allowed_origins is set, the Origin must be present and on the list, otherwise 403. A missing Origin is refused. Without an allowlist the request passes, as before.
  • same-origin, none and any other value pass, as before.
  • With no Sec-Fetch-Site, nothing changes.

The allowlist match is now one helper (_is_allowed_origin) that refuses an Origin that does not normalize to scheme://host. An unparseable Origin therefore cannot match an unparseable allowlist entry. Before this change that case was already safe without Sec-Fetch-Site, and is now covered on the same-site path too.

Verify the cookie's inner access token (server#231)

In the guard's cookie path, after the HS256 cookie verifies, the API token inside it now goes through the same verification the bearer path uses (_verified_access_token): RS256 against the auth server JWKS, iss = auth_server_issuer, aud = audience, exp and nbf, typ: access, and a non-empty sub. The inner sub must equal the cookie's sub, otherwise the answer is 401. roles and recovery come from the verified token. email and phone still come from the cookie. In this adapter auth_server_url is required and audience defaults to the issuer, so the check always runs. It fails closed: a JWKS fetch failure, or an empty issuer or audience, means 401. This replaces the unverified _is_access_token read.

Cookie lifetime vs the inner token's exp

The access cookie (its Max-Age and the HS256 exp) is sized from the session response's ttl. The API sets that from the same access_token_ttl it uses for the access token's exp (sessionIssuance.ts and the /refresh controller in seamless-auth-api; signAccessToken uses setExpirationTime(access_token_ttl)). So the cookie outlives the inner token only by the round trip between the API signing the token and the adapter signing the cookie, plus any clock difference between the two hosts. The 5 second leeway absorbs both. Every place that writes an access cookie (sign-in routes, silent refresh, POST /auth/refresh, the refresh after recovery enrollment) writes it from a fresh, just-verified token. No path re-signs a cookie around an old token. A session that is valid now does not start failing. In the last seconds of a cookie's life, a skewed clock could produce a 401 slightly before the browser drops the cookie. The browser would then refresh through the auth routes as it does at expiry.

Other places that read the access cookie

  • _credential_for (manifest auth routes such as /users/me, the admin routes and logout): forwards the inner token to the API as Bearer, and the API verifies it. No claim is trusted locally, so unchanged.
  • _optional_pre_auth (magic link verify): forwards the ephemeral cookie's token to the API. Unchanged.
  • _is_recovery_token on the enrollment routes: read only after the API answered 2xx to that same token, so it has been verified upstream. Unchanged.
  • _silent_refresh and _refresh_after_enrollment: read the refresh cookie, not the access cookie. Each new access cookie goes through _verify_session (JWKS) before it is issued. Unchanged.
  • console (admin dashboard proxy): serves static assets and reads no cookie. Unchanged.
  • There is no role-check helper in this adapter. Role checks are done by the adopter on User.roles, which now comes from the verified token.

Tests

  • Origin: a decision table in tests/test_adapter.py mirroring the JS originGuard.test.js. It covers same-site with an allowlist (allowed, other, missing and null Origin), same-site without an allowlist, cross-site always blocked, same-origin always passing, Lax cookies unaffected, GET and OPTIONS ungated, and unparseable Origins against an unparseable allowlist. Guard-level same-site tests are in tests/test_guard.py.
  • Inner token: a cookie with a valid HMAC whose inner token is signed by another RSA key, unsigned, HS256 under the cookie secret, wrong aud, wrong iss, expired, not yet valid, ephemeral, missing sub, or garbage gives 401. An inner sub that differs from the cookie sub gives 401. Cookie roles ["admin"] with token roles ["user"] give ("user",). Recovery is taken from the token. JWKS unreachable or 5xx gives 401. An empty audience gives 401 without a fetch. FastAPI and Django guard tests cover a forged and a mismatched cookie. Existing guard tests already signed real RS256 inner tokens from the test JWKS. The access() fixture with an opaque token is only used on auth routes, where the token is forwarded upstream.
  • With the old _adapter.py, 22 of the new tests fail.

Checks

uv run ruff check .          All checks passed!
uv run ruff format --check . 34 files already formatted
uv run mypy                  Success: no issues found in 13 source files
uv run pytest                272 passed

README "The guard" section is updated.

Closes #15

…ner access token

A same-site request is held to allowed_origins when the adopter set one, and
the guard now verifies the auth API access token inside the access cookie
against the JWKS, requiring a matching sub and taking roles and recovery
from the verified token.

Closes #15
@Bccorb
Bccorb merged commit 2e52141 into main Oct 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Origin allowlist with Sec-Fetch-Site, and verify the cookie's inner access token

1 participant