Skip to content

Add PartitionedCookies so an embedded app can keep an HttpOnly session - #318

Draft
paskal wants to merge 1 commit into
go-pkgz:masterfrom
paskal:feat/partitioned-cookies
Draft

paskal wants to merge 1 commit into
go-pkgz:masterfrom
paskal:feat/partitioned-cookies

Conversation

@paskal

@paskal paskal commented Aug 23, 2026 •

Copy link
Copy Markdown
Collaborator

Reopening the work from #315, which I closed on a mistaken reading. This time with measurements instead of an argument.

Why I closed it, and why that was wrong

I closed #315 because remark42's AUTH_SEND_JWT_HEADER already delivers cross-domain persistence, so the library change looked redundant. That is true about persistence and misses what the header path costs. It works by having the server return the token in X-JWT so the frontend can write its own cookie, which means the token has to be readable from JavaScript. Partitioned on the server-set cookie gets the same persistence while the cookie stays HttpOnly. Those are not the same outcome, and I treated them as if they were.

The rig

Two genuinely different registrable domains, not hostname aliases: the application on subdomains of one, the embedding page on another, real wildcard certificate, real DNS, no resolver flags. Chromium, Firefox and WebKit through Playwright; Safari 27 through its own WebDriver, so the Safari result is Safari and not an approximation.

Every "blocked" result carries a control. An ordinary SameSite=None cookie and a valid Partitioned sentinel are written from inside the embedded frame and read back through document.cookie in the same evaluate. The ordinary one must be gone and the sentinel present, otherwise the run is not blocking anything and its passes mean nothing. That control caught two runs I would otherwise have reported as evidence.

The result

Both configurations keep a reader signed in across a reload with third-party cookies blocked. The difference is what JavaScript can reach. Read from inside the frame, on real Safari, blocking confirmed by the control:

configuration document.cookie inside the frame
SendJWTHeader, frontend writes the cookie xdpart=1; JWT=eyJhbGci…
PartitionedCookies, server writes the cookie xdpart=1; XSRF-TOKEN=c1090a08…

The first row is the token, readable by any script on the page. In the second the JWT is absent from document.cookie because it is still HttpOnly; only the XSRF value is visible, which is the double-submit pattern working as designed.

The Set-Cookie this produces:

JWT=…; Path=/; Max-Age=720000; HttpOnly; Secure; SameSite=None; Partitioned
XSRF-TOKEN=…; Path=/; Max-Age=720000; Secure; SameSite=None; Partitioned

Per engine, with the partitioned build: Chromium keeps the session under enforced partitioning, WebKit keeps it, Safari 27 keeps it, Firefox keeps it in accept-all and under Total Cookie Protection. Firefox with "block all third-party cookies" loses it, and so does every other configuration, because that mode discards partitioned cookies as well; nothing in this library can change that.

What it does not fix

Not OAuth. The partition key is the top-level site at the moment the cookie is set, and an OAuth callback runs in a popup that is its own top-level context, so the callback cookie is keyed to the auth host and the embedded frame never sees it. The flows this helps are the ones whose Set runs inside the frame. The option's godoc says so, and says what OAuth would actually need.

The change

PartitionedCookies on Opts, threaded to the four cookies Set and Reset build, mirrored in both modules. It requires SecureCookies, since http.Cookie.Valid rejects Partitioned without Secure and browsers drop such a cookie; the constructor warns when that combination is missing rather than emitting something the browser will silently discard.

Reset also clears the unpartitioned form. A partitioned expiry does not match an unpartitioned cookie, so a pair written before the option was enabled would otherwise survive sign-out: the browser sends both, Request.Cookie returns the first, and the refresh in middleware turns the stale one back into a live session.

Tests in both modules, and the library's own suites pass under -race with lint clean.

Coverage

Rebasing onto current master hit conflicts in auth_test.go and I resolved them by taking master's side, which silently dropped the test that shipped with this change. Coveralls caught it.

TestNewService_PassesCookieOptionsToTokenService is the recovered one, and it exists because that token.Opts literal has dropped a field twice before, for SameSite and for XSRFIgnoreMethods, both in diffs shaped like this one: a re-indent plus one added key, with nothing failing either time. TestNewService_WarnsOnPartitionedWithoutSecure covers the warning branch, which is the only signal a caller gets for a pairing that produces a header browsers discard silently.

Both mutation-checked against each other: removing the struct field fails the first and not the second, removing the warning fails the second and not the first.

@coveralls

coveralls commented Aug 23, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 33853851331

Coverage increased (+0.06%) to 86.727%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: 46 of 46 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3865
Covered Lines: 3352
Line Coverage: 86.73%
Coverage Strength: 9.92 hits per line

💛 - Coveralls

@umputun umputun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the legacy-clearing branch in Reset needs its counterpart in Set, otherwise the feature never reaches an existing session.

Reset handles this already (token/jwt.go:363, same in v2) and its comment states the premise exactly: a pair written before the option was on survives a partitioned expiry, the browser sends both, Request.Cookie returns the first. Set writes only the partitioned pair and never touches the old one.

so, deployment has sessions, operator turns PartitionedCookies on, reader comes back without signing out:

  1. Get reads the legacy cookie, since it comes first in the header
  2. it is past TokenDuration, middleware refreshes it
  3. Set writes fresh claims into the partitioned jar, legacy cookie untouched
  4. next request, back to 1

the refresh never sticks. Anything ClaimsUpd sets on refresh, role, admin, a blocked attribute, never reaches the reader, and no pre-existing session gets the CHIPS behaviour this exists for. Runs until the legacy cookie hits its original CookieDuration, so a month on a typical setting. It is quiet because the XSRF pair shadows in step, so double-submit still matches and nothing 401s.

confirmed on Chromium: both forms are stored, both sent, legacy first. Not claiming every browser orders it that way, RFC 6265bis makes it a SHOULD and says not all UAs comply, but Chromium is the browser CHIPS is for.

fix is a legacy-expiry branch in Set, not a mirror of Reset: keep writing the live partitioned pair and additionally expire the unpartitioned one. Reset expires both because it is signing out. Mirrored in v2, and worth a test for the coexistence case since nothing covers it now.

one merge note: #317 carries the same identicon hunk as this branch in provider/dev_provider_test.go and provider/custom_server_test.go, and its version is the superset. Cleanest is #317 first, then this rebases and drops its copy.

@paskal
paskal force-pushed the feat/partitioned-cookies branch from 142c48f to c4752d7 Compare August 25, 2026 09:52
@paskal

paskal commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Both #317 notes addressed, and rebased onto master now that it carries #317.

The identicon uniqueness gap is closed. You were right that it was a deliberate trade and not something I ran out of room for; my PR body flagged it and then left it, which is the wrong place to leave a known gap. TestGenerateAvatar_DiffersPerUser generates for two stable user ids, decodes both and asserts the pixels differ, not the encoded length, since the length is the thing that moved with the Go release. It also asserts the same user twice is pixel-identical, because an identicon that churns per login is the other direction this can fail and nothing covered that either.

Mutation-checked the way the finding describes: making Draw ignore its user argument fails this test and nothing else in either module, which is exactly the mutation the byte counts used to catch by accident.

The duplicate hunks are gone. provider/dev_provider_test.go and provider/custom_server_test.go now take master's version, which is the superset carrying the uniformImage assertion this branch's copy lacked. The rebase conflicted on precisely the four files you named.

One thing worth recording, since it nearly cost the same test twice. The earlier rebase of this branch hit a conflict in auth_test.go, I resolved it by taking master's side, and that silently dropped the plumbing test that ships with this change; coveralls caught it. The same conflict recurred on this rebase, so this time I took master's file and re-applied the two tests deliberately rather than letting the resolution decide. Both are present and passing.

@paskal
paskal marked this pull request as draft August 26, 2026 17:55
@paskal
paskal force-pushed the feat/partitioned-cookies branch from c4752d7 to a828075 Compare September 4, 2026 08:23
An auth cookie set from an embedded context is dropped by browsers
enforcing CHIPS unless it carries the Partitioned attribute. The option
threads through Opts to the token service and onto both the JWT and XSRF
cookies, and it is opt-in: nothing changes for a first-party deployment.

Partitioned only means anything alongside Secure and SameSite=None, so
setting it without Secure logs a warning instead of emitting a cookie the
browser will reject. Switching a deployment on also leaves an unpartitioned
cookie behind under the same name, so the reset path clears the legacy pair
as well as the new one.

The plumbing test exists because the token.Opts literal in NewService has
silently dropped a field twice, bd39e5e for SameSite and 59656e4 for
XSRFIgnoreMethods, both in diffs shaped exactly like this one: a re-indent
plus one added key. Neither failed a test, because nothing asserted the
plumbing rather than the behaviour.

The avatar assertion goes the same way: the identicon test proved a PNG
came back, not that it depended on the user, so a generator returning one
fixed image would have passed it.
@paskal
paskal force-pushed the feat/partitioned-cookies branch from a828075 to 9d5561d Compare September 4, 2026 08:31
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.

3 participants