Correct R1 in the frontend plan: scope it to the flows that can meet it

The acceptance criteria promised sign-in with any configured provider under
third-party cookie blocking, which OAuth as built cannot satisfy: the callback
runs in a popup, a top-level context of its own, so the cookie it sets is keyed
to the auth host and the frame embedded on the other domain is a different
partition that never sees it. The criteria now name email, Telegram and
anonymous, and OAuth carries its own paragraph explaining why it is out and what
would bring it in, which is the server-mediated one-time code the constraints
below already describe.

Two claims went stale alongside it. Master meets the criterion today with
AUTH_SEND_JWT_HEADER on, because fetcher.ts writes the cookie from inside the
frame and the token never travels as a third-party cookie, so the remaining gap
for those flows is documentation and not code. The server-set cookies still lack
Partitioned, but nothing depends on them surviving in a third-party frame any
more, so the upstream work they were waiting on buys no flow that does not
already work.
This commit is contained in:
Dmitry Verkhoturov
2026-08-24 01:09:05 +01:00
parent 3286f028e3
commit 33e93a427d
@@ -33,23 +33,44 @@ users report against most. The target arrangement is remark42 serving from its o
documented in `site/content/docs/manuals/separate-domain/index.md`, so operators follow it and then
find that authentication behaves differently from the same-domain case.
**Acceptance criteria.** A reader on `food.com` can sign in with any configured provider, post,
reload the page, and still be signed in, on a browser that blocks unpartitioned third-party cookies.
Nothing short of the reload proves it: the widget holds a token in memory for the life of a page, so
a test that signs in and posts without reloading passes while the persistence is entirely broken.
**Acceptance criteria.** A reader on `food.com` signs in through email, Telegram or anonymous,
posts, reloads the page, and is still signed in, on a browser that blocks unpartitioned third-party
cookies. Nothing short of the reload proves it: the widget holds a token in memory for the life of a
page, so a test that signs in and posts without reloading passes while the persistence is entirely
broken.
**Where that stands.** 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 authentication half is not covered and
cannot be until the e2e stack speaks https, because an embedded cookie needs `SameSite=None`, which
browsers accept only with `Secure`.
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 rather than 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.
`ALLOWED_HOSTS` sets the CSP `frame-ancestors` and `AUTH_SAME_SITE=none` lets auth cookies be set
from any embedding domain. OAuth is what visibly fails off-domain (discussion #1139). Telegram,
email and anonymous work where third-party cookies are still permitted, and stop working where they
are not: the server's cookies carry no `Partitioned`, and the header fallback is off by default, so
nothing saves them once the browser blocks unpartitioned third-party cookies.
**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 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`.
#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 last case needs `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.
`ALLOWED_HOSTS` sets the CSP `frame-ancestors` and `AUTH_SAME_SITE=none` lets the server's auth
cookies be set from any embedding domain. Those server-set cookies carry no `Partitioned`, so they
are the ones the browser drops; what survives the drop is the header fallback above.
There is a second mechanism aimed squarely at this, `AUTH_SEND_JWT_HEADER`, which returns the token
in a response header so the client can present it without relying on an ambient cookie. #1877 was
@@ -58,8 +79,9 @@ 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 still need the same treatment, and that is upstream work in
`go-pkgz/auth`.
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.
Constraints on any frontend design:
@@ -75,8 +97,9 @@ Constraints on any frontend design:
`go-pkgz/auth/v2@v2.2.0` cannot emit `Partitioned` at all. Both cookies are hand-built in
`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. That is a PR to the library before anything in
this repo changes
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 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
@@ -460,7 +483,8 @@ 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, the upstream `Partitioned` work in `go-pkgz/auth`.
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.
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