Correct the R1 mechanism: the attribute carries the reload, not the writer

A reviewer pass found the central claim inverted. The text said the token
never travels as a third-party cookie because fetcher.ts writes it inside the
frame. It does travel as one: activeJwtToken is a module-level variable filled
only from the response header, nothing reads the JWT cookie back, and getCookie
is called once in the whole app for XSRF-TOKEN, so the first request after a
reload sends no header and the token arrives ambiently. What spares it from
blocking is the Partitioned attribute authCookieOptions sets, which is what
#2214's control cookie exists to prove.

Consequences elsewhere in the section. Saying the upstream Partitioned work
buys no flow that does not already work was false: AUTH_SEND_JWT_HEADER ships
off, so in the default configuration nothing writes a partitioned cookie at
all. The OAuth handoff was described only in the shape that needs that upstream
work, when answering the redemption with X-JWT needs nothing upstream. The
partition-key description was a counterfactual, since a cookie with no
Partitioned attribute has no partition key, and the SameSite clause did not
apply to a top-level callback navigation under AUTH_SAME_SITE=none.

Also: the documentation gap is two edits, since the parameters page documents
the flag but still promises SameSite=Strict and a __Host- prefix that #2197
removed; #2214 adds three TLS functions and only two are table-driven; the task
list still claimed R1 has no e2e coverage; the constraint bullet still described
setAuthCookie's pre-#2197 behaviour; and the Path B cost line had swapped the
real upstream dependency for an invented one. Wrapped to 100 columns.
This commit is contained in:
Dmitry Verkhoturov
2026-08-24 01:09:05 +01:00
parent 6831206574
commit be61b5873a
@@ -40,41 +40,66 @@ page, so a test that signs in and posts without reloading passes while the persi
broken.
OAuth sits outside that criterion as the flow is built today, and no frontend change brings it
inside. The provider callback lands in a popup, which is a top-level context of its own, so the
cookie set there is keyed to the auth host and not to `food.com`, and the frame embedded in
`food.com` is a different partition that never sees it. `SameSite` compounds it, being judged
against the top-level site and not against the request's own origin, and the cookie is
`HttpOnly` besides, so the popup cannot read it to hand it over. None of that is a defect; it is the
partitioning model behaving as specified. Bringing OAuth inside the criterion needs the
server-mediated handoff described in the constraints below, where the popup posts a one-time code
back to its opener, the frame redeems it over XHR, and the `Set-Cookie` answering that request is
issued to the embedded context and keyed to `food.com`. That is a backend feature and out of scope
here. Until it exists, an operator who needs OAuth off-domain serves remark42 from the same
registrable domain as the site, or accepts that OAuth readers sign in on the instance's own origin.
inside. The provider callback is a top-level navigation in a popup, so `Service.Set` writes its
cookie there as an ordinary first-party cookie for the auth host, carrying no `Partitioned` and so
having no partition key at all. The frame on `food.com` sees it only as an unpartitioned third-party
cookie, which is exactly what a blocking browser refuses. The cookie is `HttpOnly` besides, so the
popup cannot read it and hand it over in script. `SameSite` is not what obstructs this: the
separate-domain manual has operators set `AUTH_SAME_SITE=none`, and the callback navigation is
top-level regardless.
Bringing OAuth inside the criterion needs a server-mediated handoff, where the popup posts a
one-time code to its opener and the frame redeems it over XHR. Two shapes exist and they cost
differently. If the redemption answers with `Set-Cookie`, that cookie has to carry `Partitioned` to
be stored at all, which is the upstream library work described below. If it answers with `X-JWT`
instead, `fetcher.ts` writes the partitioned pair itself and nothing upstream has to change, which
makes it the cheaper of the two. Either is a backend feature and out of scope here. Until one
exists, an operator who needs OAuth off-domain serves remark42 from the same registrable domain as
the site, or accepts that OAuth readers sign in on the instance's own origin.
**Where that stands.** Master satisfies the criterion today, provided `AUTH_SEND_JWT_HEADER` is on.
The server returns the token in `X-JWT`, `fetcher.ts` writes the `JWT` and `XSRF-TOKEN` cookies
itself, and because that write runs inside the frame the pair is keyed to `food.com` and comes back
on the reload. The token never travels as a third-party cookie, which is why the browser's blocking
never reaches it. The flag is off by default and `site/content/docs/manuals/separate-domain/index.md`
does not mention it, so the arrangement works and nobody is told how; documenting it is the whole
remaining gap for the flows named above.
The server returns the token in `X-JWT` and `fetcher.ts` writes the `JWT` and `XSRF-TOKEN` cookies
itself through `authCookieOptions`, which marks them `SameSite=None; Secure; Partitioned` in a
third-party context. The attribute is what carries the reload, not the fact that the write happened
in the frame: `activeJwtToken` in `fetcher.ts` is a module-level variable filled only from the
response header, nothing ever reads the `JWT` cookie back, and `getCookie` is called once in the
whole app for `XSRF-TOKEN`. So the first request after a reload sends no header and the token
arrives as an ambient cookie, third-party by definition inside the frame, and survives only because
it is partitioned. #2214's control cookie encodes exactly that: an unpartitioned `SameSite=None`
cookie written by the same script in the same frame has to be dropped, or the case declares itself
vacuous.
The e2e suite covers the rendering half through `TestCrossOrigin_WidgetRendersOnAnotherOrigin`, which
Documenting it is the remaining gap for the flows named above, and it is two edits rather than one.
`site/content/docs/manuals/separate-domain/index.md` covers only `ALLOWED_HOSTS` and
`AUTH_SAME_SITE`, so it needs the flag added. `site/content/docs/configuration/parameters/index.md`
does document the flag, but its mitigation list still promises `SameSite=Strict` cookies and a
`__Host-` prefix on https, neither of which `authCookieOptions` does in a third-party context since
#2197, so that page needs correcting. Its warning that the flag increases XSS exposure compared with
server-set `HttpOnly` cookies is the trade-off an operator has to be handed in the same breath as
the recommendation.
The e2e suite covers the rendering half through `TestCrossOrigin_WidgetRendersOnAnotherOrigin`,
which
proves the document loads on another origin and reports itself through postMessage across the
boundary, and `ALLOWED_HOSTS` refusal through `TestCrossOrigin_DisallowedHostNeverReportsInited`.
The open #2214 adds the authentication half over TLS, the reload included, and runs it once more
against a browser configured to block third-party cookies. That second case needs
`IgnoreDefaultArgs`, because Playwright's own `--disable-features` list switches partitioning off and
`IgnoreDefaultArgs`, because Playwright's own `--disable-features` list switches partitioning off
and
beats the flags passed through `Args`, which would leave the case asserting nothing.
Both of its TLS cases are table-driven over two flows, so anonymous and email are each measured in a
third-party frame with the reload, and each again under enforced partitioning, every subtest carrying
`TestHTTPS_CrossOriginSignInSurvivesAReload` and `TestHTTPS_SessionSurvivesThirdPartyCookieBlocking`
are table-driven over two flows, so anonymous and email are each measured in a third-party frame
with the reload, and each again under enforced partitioning, the blocking case giving every subtest
its own control cookie and its own partitioned-JWT guard in a fresh context so neither can pass
vacuously. Telegram is the one of the three still resting on inference. It is exercised nowhere and
vacuously. `TestHTTPS_AuthCookiesCarryTheThirdPartyForm` is the third and reads the attributes out
of the browser store directly. Telegram is the one of the three still resting on inference. It is
exercised nowhere and
cannot be until #2208 makes the Telegram API base URL configurable, because without that the stack
cannot answer as Telegram; `go-pkgz/auth` #316 is the change that would let the suite measure it. The
inference itself is that the client-side writer keys off `X-JWT` on any auth response and not off the
cannot answer as Telegram; `go-pkgz/auth` #316 is the change that would let the suite measure it.
The
inference itself is that the client-side writer keys off `X-JWT` on any auth response and not off
the
provider, so nothing in it distinguishes one flow from another. The distinction is worth keeping
visible, because a criterion resting on flows the suite cannot reach is a milder version of the
defect this section was rewritten to remove.
@@ -90,9 +115,12 @@ first half. Its persistence never worked on https: the client wrote its copy und
prefix that neither the backend nor the widget's own reader ever asks for, and marked it
`SameSite=Strict`, which is never sent from a third-party frame. Both are corrected here, and the
frontend now marks its cookies `SameSite=None; Secure; Partitioned` when it detects a third-party
context. The server-set cookies are untouched and still carry no `Partitioned`, but with the header
path in place nothing depends on them surviving in a third-party frame, so partitioning them upstream
buys no flow that does not already work.
context. The server-set cookies are untouched and still carry no `Partitioned`. That is what makes
the
upstream work matter rather than what excuses it: `AUTH_SEND_JWT_HEADER` defaults to false, so in
the configuration remark42 actually ships nothing writes a partitioned cookie anywhere, and
partitioning the server's pair is what would carry email, anonymous and Telegram off-domain without
asking operators for a flag that costs them XSS exposure.
Constraints on any frontend design:
@@ -109,14 +137,17 @@ Constraints on any frontend design:
`Service.Set` with only `HttpOnly`, `Path`, `Domain`, `MaxAge`, `Secure` and `SameSite`, and
`AUTH_SAME_SITE` in `cmd/server.go` offers `default`/`none`/`lax`/`strict` with no partitioned
axis, since `Partitioned` is a separate attribute. The first option therefore starts with a PR to
the library; the second needs nothing upstream and is already shipped, which is why it is the one
R1 leans on
the library. The second needs nothing upstream and already works, which is why R1 leans on it, but
it is gated behind a flag that ships off and costs XSS exposure, so it answers the requirement
without being the answer an operator gets by default
- the popup-to-iframe handoff cannot be done in JS. The JWT cookie is `HttpOnly: true`, set in
`Service.Set`, so the top-level popup cannot read it to hand over, and the receiving end would not
work either: `setAuthCookie` in `cookies.ts` writes `SameSite: 'Strict'` under a `__Host-` prefix
with no `Partitioned`, and Strict is never sent from a third-party frame. The handoff has to be
server-mediated, a one-time code redeemed for a `Set-Cookie … Partitioned` issued from the
embedded context. Email and anonymous authenticate over XHR from inside the iframe, which keeps
`Service.Set`, so the top-level popup cannot read it to hand over. The receiving end is no longer
the obstacle it was: since #2197 `authCookieOptions` in `cookies.ts` returns
`SameSite=None; Secure; Partitioned` in a third-party https context and adds no `__Host-` prefix.
What remains missing is any route from the popup's storage bucket to the frame's, so the handoff
has to be server-mediated, a one-time code redeemed either for a `Set-Cookie … Partitioned` or,
more cheaply, for an `X-JWT` the frame's own writer turns into the partitioned pair. Email and
anonymous authenticate over XHR from inside the iframe, which keeps
them working while third-party cookies are permitted, but XHR does not create a partition by
itself, so they need `Partitioned` for the same reason OAuth does
- the failure mode is silent rather than an error, which is why #1139 reads as a hang. OAuth
@@ -218,7 +249,8 @@ skipped entirely. That would put a live JWT into `Referer`, into history, and in
the route is one that logs bodies. It is unreachable in any case, since the JWT cookie is `HttpOnly:
true`, set in `Service.Set`, and no JS can read it to build the URL.
So anonymous-first is what the current configuration gives, and the defensible Path B position is that
So anonymous-first is what the current configuration gives, and the defensible Path B position is
that
making the document authenticated is a scoped piece of security work with its own cost. Until that
is costed, budget for anonymous-first: render anonymous, then hydrate the user state over XHR, which
does carry the header. Anonymous-first is also what a shared cache wants, though see the hydration
@@ -366,8 +398,7 @@ change and collapse persistence (`thread_test.go`), anonymous and email auth (`a
profile iframe and last-comments (`widgets_test.go`).
What remains uncovered is a different list, and it is the contract surface rather than the
behaviour: a cross-origin host page (every host page in the suite is served from the widget origin,
so R1 has no coverage at all), the `comments.html` fallback, `remark_config` fields (`url`,
behaviour: the `comments.html` fallback, `remark_config` fields (`url`,
`page_title`, hash deep links, `max_shown_comments`, the three `show_*_subscription` flags,
`__colors__`), the listener leak on a repeated `createInstance`, timezone-local date rendering, the
unknown-locale fallback, and the composer.
@@ -494,8 +525,9 @@ compiled, it is what verifies it.
**Cost**: months to an opt-in parallel UI reads as a floor derived from the optimistic architecture,
and the optimistic architecture does not hold. Anonymous-first is forced rather than chosen, so the
fragment layer reproduces the entire authenticated tree rather than a delta; add the three
subscription flows and, if R1 is to be honoured, a server-side equivalent of the header fallback,
since a server-rendered fragment has no client-side writer to key the cookie to the embedding site.
subscription flows and, if R1 is to be honoured, the upstream `Partitioned` work in `go-pkgz/auth`,
since a server-rendered fragment has no client-side writer and its `Set-Cookie` is stored in a
third-party frame only when it carries the attribute.
On one contributor the realistic figure is long enough that the plan's own warning applies to the
schedule and not only to the design. Deletes the entire npm toolchain. **Against it**: 2,400 lines
of the most stateful code get rewritten; a second HTML-fragment API surface becomes permanent