ReviewOS

stacks/postline

Browser sign-in is impossible: the client reads auth-token from document.cookie, but the server sets it HttpOnly

#45
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Signing in through a browser cannot establish a session. The server authenticates correctly and sets a correct cookie; the client then tests for that cookie in a way that can never succeed, and bounces straight back to /login.

This blocks every authenticated surface — composer, queue, settings, accounts, inbox, discover — so it is the blocker for "finished and usable this week".

What happens

  1. POST /api/login returns 200 with a token payload, and sets:

    set-cookie: auth-token=…; Path=/; Max-Age=2591999; HttpOnly; SameSite=Lax
  2. login.stx's storeTokenAndEnter runs useCookie('auth-token', { maxAge }).set(token), then location.assign('/composer').

  3. /composer renders layouts/app.stx, whose pre-paint guard runs if (!isNativeApp && !/(?:^|;\s*)auth-token=/.test(document.cookie)) window.location.replace('/login').

  4. Back to /login. Repeat forever.

Why it cannot work

HttpOnly means JavaScript can neither read nor write that cookie. Three client-side sites assume it can:

SiteCodeActual behaviour
resources/views/login.stx, storeTokenAndEnteruseCookie('auth-token', { maxAge }).set(token)Silent no-op. The browser discards a JS write to a name held by an HttpOnly cookie.
resources/layouts/app.stx, auth guard`!/(?:^;\s*)auth-token=/.test(document.cookie)`
resources/layouts/app.stx, cookie→bearer bridgedocument.cookie.match(/(?:^|;\s*)auth-token=([^;]+)/)?.[1]Always undefined, so no Authorization header is attached and every /api/ call is unauthenticated.

HttpOnly is not configurable — @stacksjs/auth/dist/cookie.js builds the header with the flag as a literal:

parts = [
  `${cookieName(options)}=${encodeURIComponent(token)}`,
  `Path=${options.path ?? configured.path ?? "/"}`,
  `Max-Age=${options.maxAge ?? configured.maxAge ?? defaultMaxAge()}`,
  "HttpOnly",
  `SameSite=${options.sameSite ?? configured.sameSite ?? "Lax"}`,
]

Evidence

A beforeunload listener recorded document.cookie at the instant storeTokenAndEnter navigated. Only the CSRF token was present — auth-token was never written:

cookieAtUnload: "X-CSRF-Token=d5456fee…"

Writing the exact string useCookie serialises fails, while an unrelated name written seconds earlier succeeds:

document.cookie = 'probe-raw=1; path=/; max-age=60'                              // sticks
document.cookie = `auth-token=${token}; path=/; max-age=2592000; SameSite=Lax`   // does NOT stick

The server session is fine; it is only unreachable from the client. With the cookie in a curl jar the API still rejects the call, because the Auth middleware wants a Bearer header — which is exactly what the bridge was supposed to supply from the cookie it cannot read:

curl -b jar.txt /api/ot/subscribers
→ {"error":"Unauthorized","message":"Unauthorized. No token or session provided.","status":401}

Why nobody noticed

The desktop shell does not go through any of this. allowsNativeAppWithoutLogin (app/Support/Auth/native.ts) skips the token entirely for x-ot-native: craft on a loopback, non-production host:

curl -H "x-ot-native: craft" /api/ot/subscribers  →  {"ok":true,"data":{"subscribers":[],…}}
curl                         /api/ot/subscribers  →  401

So the app works when driven from the desktop shell against a dev server, and is unusable in a browser. Anyone testing the former would not see this.

Fix directions

  1. Let the API accept the HttpOnly cookie as a session. The cookie is already correct and already carries the session; the server should honour it on /api/ requests. This deletes all three client-side workarounds and the bridge with them. Recommended.
  2. Have the client ask instead of sniff. A /api/me (or a server-rendered flag) decides signed-in state, rather than inspecting a cookie it cannot see. Fixes the guard and storeTokenAndEnter; the bridge still needs (1).
  3. Drop HttpOnly. Works, and gives up the XSS protection the framework deliberately added. Not recommended, and it needs an upstream change since the flag is hardcoded.

Whichever is chosen, storeTokenAndEnter's useCookie(...).set() should go — it has never done anything.

Reproducing

buddy migrate:fresh --force && buddy seed   # seeds dev@theopentimes.org
buddy dev --frontend
buddy dev --api

Sign in at /login with the seeded account. The POST returns 200; the page returns to /login with no auth-token in document.cookie.

9 days ago

Fixed in b98b84f (auto-closed by the push).

The root cause was drift in this app's app/Middleware/Auth.ts, not a framework limitation. The override exists only for the native-shell bypass, but it had been left reading the bearer header and session_id and nothing else, while the framework default it overrides had grown to read the auth cookie as well. It now uses the framework's own requestToken resolver (Authorization header, then httpOnly auth cookie) and stamps the resolved user, matching storage/framework/defaults/app/Middleware/Auth.ts.

That is fix direction 1 from the issue, and it did delete all three client-side workarounds:

  • login.stx no longer writes the cookie it cannot write. It records ot-signed-in, a marker carrying no token, given the session's own lifetime.
  • The pre-paint guard reads that marker instead of testing for auth-token=.
  • The cookie-to-bearer bridge is gone. What is left is the native-shell header plus a 401 watcher, so a marker that outlives its session is cleared by the API's first answer rather than leaving a broken shell.

Two further instances of the same bug turned up while fixing it:

Sign-out never signed anyone out. settings.stx ran authToken.set('') against the same httpOnly name, so the session was neither revoked nor cleared — the token stayed valid for its remaining 30 days and a copied cookie kept working. /api/logout is now routed to the framework's LogoutAction, which revokes and clears.

First-run registration could not sign its owner in. RegisterFirstUserAction returned the token in the body only and set no cookie, so creating the workspace account bounced straight back to /login. It now sets the same session cookie LoginAction does. Its password floor also moved 6 → 8, because register() rejects anything under 8 itself — a 6- or 7-character password passed this action's check and then failed inside the framework with a different message and no account created.

Verification

Against a live API + frontend, with the seeded account:

POST /api/login (via the frontend proxy)   200, Set-Cookie: auth-token=…; HttpOnly
GET  /api/ot/subscribers   cookie only     200     (no cookie: 401)
GET  /api/ot/providers     cookie only     200     (no cookie: 401)
GET  /api/ot/queue         cookie only     200
GET  /api/ot/analytics     cookie only     200

The curl-with-jar call quoted in this issue as returning 401 now returns 200.

In a browser: sign-in lands on /composer and stays there, document.cookie shows only ot-signed-in=1 and the CSRF token (the session correctly invisible), and composer, queue, analytics and settings all load with every /api/ot/* call at 200.

Sign-out returns to /login and leaves the token genuinely dead — replayed as a cookie: 401; replayed as a bearer header: 401.

A forged ot-signed-in=1 with no session behind it is cleared by the first 401 and bounced, so a stale marker self-heals.

x-ot-native: craft on loopback still bypasses, unchanged.

Suite: 766 pass, 0 fail. pickier and typecheck clean.

Sign in to comment on this issue.