epic 2.1: logout refuses the session token, not just the browser cookie - #57
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
c1bce92)142321e)T2.1.22
POST /api/auth/logoutdoes not invalidate the session tokenThe 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
200logout answered200on/api/keys, and/api/auth/sessionstill saidauthenticated: true.The item named two shapes and said both cost something. A counter in
auth.jsonstamped into thetoken, 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.mddecides it twice over: theproduct 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 evictionPOST /api/auth/passwordalready performs forthe 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
200and writes nothing, so the route cannot beused to sign the operator out.
Tests:
npm test256 pass. Smoke gained four cases and runs 309 ok, exit 0. Each new assertion wasproven 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 unreadableauth.jsonthe routeanswered
500with noSet-Cookieat all, and the dashboard's handler never read the response, sothe 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.okand showed the lock screeneither way. It now confirms first, naming the reach the way
Rotate linkalready does for awide-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.
sedprints its input unchanged when thepattern misses, so a broken cookie extraction yielded a non-empty value that
401s for the wrongreason: 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_USERNAMEandARTIFACTS_ADMIN_PASSWORDbeing in the smoke shell, not only in theenvironment the server was booted with. A run without them printed
all smoke tests passedwhileproving 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
logAuthline,docs/auth.mdgained the multi-replica limitand the list of credentials the rotation leaves alone, and
docs/deploy.mdgained the matchingbullet.
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=Strictonly stopsanother 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_fieldends the suite with a bare exit 52 and noFAIL: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.mdas claiming nobody can sign the operatorout, 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.