Skip to content

epic 2.1: logout refuses the session token, not just the browser cookie - #57

Merged
kuyazee merged 2 commits into
mainfrom
task/epic-2-1-d
Sep 4, 2026
Merged

kuyazee merged 2 commits into
mainfrom
task/epic-2-1-d

Conversation

@kuyazee

@kuyazee kuyazee commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

One item, then a fix pass for the four-lens review it earned. The route answers without a key and
signs the operator out everywhere, so it got all four lenses regardless of its size.

Tests went 255 to 256. Smoke went 305 to 309 ok-lines, exit 0.

Verification page, including every mutation result and the two browser states:
https://artifacts.zonily.cloud/a/t2122-logout-verification

Items

  • T2.1.22 logout refuses the session token, not just the browser cookie (c1bce92)
  • Review findings, twelve of them (142321e)

T2.1.22 POST /api/auth/logout does not invalidate the session token

The session cookie is a signed payload with a 30 day expiry and no server-side record. Logout
cleared it in one browser and did nothing else, so a copy taken off a shared machine, a backup or a
proxy log stayed a full admin credential for the rest of that window. Reproduced on a fresh server
before touching anything: replaying the captured cookie after a 200 logout answered 200 on
/api/keys, and /api/auth/session still said authenticated: true.

The item named two shapes and said both cost something. A counter in auth.json stamped into the
token, which signs every device out at once, or a list of issued session ids, which buys per-device
logout and needs a growing server-side list plus a sweep. CLAUDE.md decides it twice over: the
product is single-admin, so per-device logout is a distinction it does not have, and it keeps no
database by default, which is the shape the id list would need. Reasoning is in
AGENT-DECISIONS.md, 2026-09-04.

So logout rotates adminSecret, the same eviction POST /api/auth/password already performs for
the same reason. No new field, no token shape change, no migration. Only a caller already holding a
live session triggers it: an anonymous POST answers 200 and writes nothing, so the route cannot be
used to sign the operator out.

Tests: npm test 256 pass. Smoke gained four cases and runs 309 ok, exit 0. Each new assertion was
proven alive against its own mutation, listed on the verification page.

What the review changed

Four lenses, twelve findings confirmed, ten fixed here and two filed.

The failed write was the one that mattered. The rotation is a storage write, and the first cut
ran it before res.clearCookie. On a read-only volume or an unreadable auth.json the route
answered 500 with no Set-Cookie at all, and the dashboard's handler never read the response, so
the operator saw the sign-in screen with a fully live admin cookie still in the browser. One reload
put them back into the dashboard. That is the exact scenario the item exists to close, reproduced
two ways. The clear now runs first, so a failed logout degrades to what logout did before it
rotated anything.

The dashboard was fire and forget. Sign out never looked at res.ok and showed the lock screen
either way. It now confirms first, naming the reach the way Rotate link already does for a
wide-reach action, and it tells the three outcomes apart. No response means the request never
landed, so it stays put and says so. A failed response still cleared the cookie, so this browser is
signed out while the token stays good elsewhere, and the toast says that rather than implying the
whole job is done.

Two of the new tests could pass while proving nothing. sed prints its input unchanged when the
pattern misses, so a broken cookie extraction yielded a non-empty value that 401s for the wrong
reason: mangling the extraction and deleting the fix stayed green together. A positive control now
reads the extracted value back as a live credential first. Separately, flipping the guard to
always-rotate left the whole suite green while handing any anonymous caller a way to sign the
operator out on a loop, so that branch is asserted too.

A skipped smoke block read as a pass. The session block is gated on
ARTIFACTS_ADMIN_USERNAME and ARTIFACTS_ADMIN_PASSWORD being in the smoke shell, not only in the
environment the server was booted with. A run without them printed all smoke tests passed while
proving nothing about login, logout or revocation. It cost me one round of false-green mutation
results before I caught it. Skips are loud now and named in the final line.

Also: a logout that revokes writes one logAuth line, docs/auth.md gained the multi-replica limit
and the list of credentials the rotation leaves alone, and docs/deploy.md gained the matching
bullet.

Filed, not fixed. T2.1.23: script inside a published artifact reaches every route the admin
cookie opens, because artifact content is same-origin with the API and SameSite=Strict only stops
another site. The security lens minted a full-scope managed key from a plain page view. Pre-existing
and far wider than this route; the durable fix is the origin split the rebrand already plans.
T2.1.24: a dropped connection inside list_field ends the suite with a bare exit 52 and no FAIL:
line, seen twice in about ten runs. Naming the failing call is small, but why a local server answers
an empty reply at all needs a look at the server rather than the script.

One correction to the security lens: it read docs/auth.md as claiming nobody can sign the operator
out, which the same-origin case contradicts. The doc now states the bound that holds, that a caller
who does not already carry the operator's cookie cannot trigger it, and names the same-origin case
directly.

Not done

The browser captures stayed local. Publishing an image to the artifacts host was refused by the
sandbox twice, so the verification page carries the readings and the exact toast copy instead of the
pictures.

…cookie

The session cookie is a signed payload with a 30 day exp and no server-side
record, so clearing it in one browser left every captured copy working for the
rest of that window. A cookie value taken off a shared machine, a backup or a
proxy log stayed an admin credential after the operator signed out.

Logout now rotates adminSecret, the same eviction POST /api/auth/password
already relies on. The account is single-admin, so signing every device out is
the whole set of sessions there are. Only a caller holding a live session
triggers the rotation, so an anonymous POST cannot sign the operator out; it
still answers 200 and writes nothing.

Smoke replays the exact cookie value captured at login after a logout and
expects 401. Unit test proves the rotation alone refuses a live session and
that the next login still works.
Ordering. res.clearCookie now runs before the rotation. The rotation is a
storage write and it can fail: on a read-only volume or an unreadable
auth.json the old order answered 500 with no Set-Cookie at all, so the browser
kept a live admin cookie while the dashboard showed the sign-in screen.
Clearing first degrades a failed logout to what logout did before it rotated
anything. Verified: the 500 still carries the clearing Set-Cookie.

Dashboard. Sign out was fire and forget: it never read the response and showed
the lock screen either way. It now confirms first, naming the reach (this signs
out every device), and reports the three outcomes apart. No response means the
request never landed, so it stays put and says so. A failed response still
cleared the cookie, so this browser is signed out while the token stays good
elsewhere, and the toast says that rather than implying the job is done. The
confirm matches the dialog Rotate link already uses for a wide-reach action.

Coverage. Three new smoke cases, each proven against its own mutation. A
positive control reads the extracted cookie value back as a live credential
before the replay, because sed prints the line unchanged when its pattern
misses, so a broken extraction used to 401 for the wrong reason and pass. An
anonymous logout is asserted to answer 200 and leave the operator's session
alive, which is the branch that must not write and had nothing holding it: a
guard flipped to always-true kept the whole suite green. The bootstrap key is
asserted to survive a logout, so a regression that rotated sessionSecret
instead would be caught.

Smoke reporting. A skipped block now prints SKIP and is named in the final
line. The session block is gated on ARTIFACTS_ADMIN_USERNAME and
ARTIFACTS_ADMIN_PASSWORD being in the smoke shell, not only in the environment
the server was booted with, and a run without them printed "all smoke tests
passed" while proving nothing about login, logout or revocation.

Audit. A logout that revokes writes one logAuth line, the way login and unlock
already do.

Docs. The claim that nobody can sign the operator out was too strong: the
cookie is SameSite=Strict, but artifact content is same-origin with the API, so
script in a published artifact can reach the route with the operator's cookie.
docs/auth.md now states the bound that actually holds and names the same-origin
case. It also carries the multi-replica limit, which is identical to the
password rotation's, and says which credentials the rotation leaves alone.
docs/deploy.md gains the matching bullet.

Two findings were filed rather than fixed. T2.1.23: script in an artifact
reaches every route the admin cookie opens, reproduced by minting a full-scope
key from a page view. Pre-existing and wider than one route; the durable fix is
the origin split the rebrand plans. T2.1.24: a dropped connection in list_field
ends the suite with a bare exit 52 and no FAIL line, and why the server answers
empty at all needs a look at the server.
@kuyazee
kuyazee merged commit c0c2aa0 into main Sep 4, 2026
7 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.

1 participant