Skip to content

fix(sessions): make ending a session durably cut off access - #138

Merged
leodip merged 27 commits into
mainfrom
issue-129-durable-session-termination
Aug 5, 2026
Merged

fix(sessions): make ending a session durably cut off access#138
leodip merged 27 commits into
mainfrom
issue-129-durable-session-termination

Conversation

@leodip

@leodip leodip commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #129. Built by /leo-run from the sealed agreement, eight stages, each reviewed by a second
agent before it was committed.

Issue: #129, ending a user session does not durably cut off access (bug, security)
Agreement: docs/issue-129-durable-session-termination.md
Read first: the deferred decisions comment
(nothing was deferred, and it says what came to you instead), then
the follow-ups comment
(four drafted issues, since reviewed and filed as #139, #140, #141 and a comment on #109, see
"Follow-ups filed" at the bottom).

What the change does

Ending a session at either explicit "end session" endpoint becomes a security action with a durable
consequence. Three gaps closed, all of which failed open, meaning they preserved access somebody
believed they had removed:

  1. An offline grant outlived its session. codes.revoked now carries the boundary: terminating a
    session marks every authorization code that session issued, in one transaction with the sid-scoped
    refresh-token sweep and the session delete. Redemption rejects a revoked code, and so does any
    refresh token descended from one.
  2. A refresh racing the termination inserted a surviving replacement. Closed structurally rather
    than by serializing anything: a rotated refresh token inherits its parent's code_id, so marking the
    code marks every present and future descendant. The racing child is born already rejected, because
    the fact predates its existence.
  3. An in-flight ceremony recreated the session it rode. /auth/completed now requires that this
    ceremony performed level 1 authentication before it mints a session, and /auth/issue refuses to
    bind a code to a session that no longer resolves, with a compensating UPDATE covering the insert
    that lands just after a termination.

A session expiring still leaves an offline grant working, which is what offline_access is for. A
session explicitly ended does not. That distinction is the one the whole design rests on, and the
documentation now draws it.

The 16 decisions behind this, including the alternatives that lost and why, are in section 3 of the
agreement. Section 4 states the property that makes it safe; section 1 has the three gaps in full.

What landed, stage by stage

Stage What landed Commit
1 codes.revoked and its sweep: migration 000026 on four engines, RevokeCodesBySessionIdentifier, and AND revoked = false on the code claim. Inert by construction, nothing reads it yet 066d289
2 The two rejections in ValidateTokenRequest, at redemption and at refresh, placed behind client authentication and PKCE per decision 7 266615b
3 TerminateUserSessionTx, the three writes in one transaction, plus the terminated_user_session audit event. Still uncalled d19d8b5
4 The behaviour changes here. Both endpoints terminate, both audit events fire after the commit, and everything that describes the action lands in the same commit: three confirmation modals in both locales, and four documentation pages f964e54
5 The /auth/completed gate, on a dedicated level 1 proof rather than on AuthenticatedAt, per decision 15 5a7c280
6 The /auth/issue liveness check, the compensating revoke, and login_required rather than a login form for a prompt=none ceremony whose session ended, per decision 16 422dd8a
7 Retention: unused revoked codes are reaped, so the new state cannot accumulate 803f593
8 Gap 2's evidence, two integration tests and no production line 682d51e

26 commits over 60 files, one code commit per stage plus the agreement following it. The agreement's
section 7 is the run log: what landed, which tiers ran, every deviation, every review finding with its
evidence and disposition, and the mutations measured at each gate.

Tests

Full suite at the close, every tier, all four engines, green. where.sh test --type all, exit 0,
zero FAIL and zero panics, "All tests completed successfully". Modules across all three Go modules;
the data tier on mysql, postgres, mssql and sqlite; the integration tier on the same four (64.8s, 74.3s,
99.2s, 43.2s), 9022 passing test lines in total.

Per tier, this change adds: migration and data-layer coverage on four engines for both new methods and
for the marker's dont-update protection; unit coverage for both validator rejections including the
ordering rows that pin them behind client authentication; unit coverage for the termination helper's
transaction threading and its zero result on every failure path; two new handler test files that did not
exist before, covering the audit payloads and that a 403 terminates nothing; and integration coverage of
both endpoints, the three ceremonies that must not recreate a session, the prompt=none refusal, and
gap 2 both reconstructed and raced.

The race reaches the window rather than approaching it. In the closing run every engine produced one
replacement token alongside the termination, and on mssql and sqlite that replacement was still live in
storage when it was presented, so the sweep never saw it and the code marker was the only thing between
it and a working grant. On mysql and postgres it landed before the sweep. Both are legitimate and
neither is asserted, so the test logs which happened.

Mutation-tested at every gate, 16 in all, each applied to the real code and reverted. The two for
stage 8 are the argument for that stage existing: with the marker's read removed, and separately with
its write removed, stage 4's headline endpoint case and stage 6's consent ceremony both stayed green,
and only stage 8's two tests failed, answering a terminated grant with a working access token.

Deviations from the plan

Recorded per stage in section 7, and none changed what the change does. The load-bearing ones:

  • Stage 2 shipped ten test rows rather than nine. Review round 1 showed the planned missing-secret
    row could not pin what it claimed, because a missing secret is refused by an earlier guard, so a
    present-but-wrong secret row was added. Demonstrated by relocating the check and watching the new row
    fail while the old one passed.
  • Stage 3 corrected a claim the implementation session had made in the mailbox: four failure rows
    catch a partially populated result, not one. The prediction was left in place and the measurement
    recorded beside it, because editing the prediction would erase the evidence that it was wrong.
  • Stage 5 stopped the run and escalated decision 15: the gate the seal specified was satisfied by
    OTP alone. Rebuilt on a dedicated level 1 proof after you answered.
  • Stage 6 stopped the run and escalated decision 16 (prompt=none cannot be restarted into a login
    form), and its round 2 found the new branch clearing the auth context after committing the client
    response, so the clear reached nothing. Fixed inside the stage, with assertions that read the
    committed response rather than a mock expectation. That defect turned out to exist at seven
    pre-existing sites, now ClearAuthContext is called after the client response is committed in seven handlers, so the clear reaches nothing #141.
  • Stage 8 staggers its race by 5 milliseconds. Released at the same instant, sqlite consistently
    produced no replacement at all, so the interesting assertions never ran. Every assertion holds at any
    value including zero, which is what keeps it a probability knob rather than a synchronization point.

Known limits, stated rather than implied

Follow-ups filed

The four drafted follow-ups were re-verified against the code after the run closed, then filed. Two
bodies changed on the way in, and both changes narrow a claim rather than widen it.

Drafted as Filed as Change on the way in
1, the UPDATE/COMMIT window #139 (bug, security, go) Body now says the window is not uniform across engines. It is open on PostgreSQL; on MySQL, SQL Server and SQLite it is closed incidentally, by InnoDB next-key locks over idx_codes_session_identifier, by a blocking READ COMMITTED read, and by writer serialization respectively. Reframed as "correctness rests on three engines' defaults by accident", which is the standard #132's criterion 4 already sets. Without this a reader reproducing on SQLite would conclude the issue is wrong
2, the logout path that writes nothing comment on #109 Two reachability claims in the draft were wrong and are removed. The admin console's account menu does not take this path: it mints a signed id_token_hint through /api/v1/account/logout-request and lands on doLogoutWithIdToken, which does tear down. And no first-party UI reaches the no-hint POST at all, since the auth server has no logout link of its own. The reachable callers are an RP omitting id_token_hint, which is conforming, and a direct request
3, the stale amr #140 (bug, security, go) Unchanged in substance. Severity stated as low: acr stays correct, so the token is internally inconsistent rather than silently over-claiming, and the narrow reachability the draft gave was confirmed, since the AuthMethods copy sits inside the hasValidUserSession block and an idle-expired session therefore leaks nothing
4, ClearAuthContext after the response #141 (bug, security, go) All seven sites re-read and confirmed, as were the two success paths that get the order right. Body gains a second defect on the same lines: two of the seven also miss a return after httpHelper.InternalServerError, so a failed redirect writes a 500 and falls through

Each was checked for duplicates against the open and closed tracker. #139 is distinct from #131 and
#132 (session-scoped code issuance, versus a user-scoped generation sweep and a family-scoped
containment cascade), and #134 held its previous shape but was closed as moot when decision 4 moved
the marker onto codes.revoked, so nothing tracked it. Nothing at all tracked #140 or #141.

Refs #129

leodip and others added 12 commits August 4, 2026 16:53
Fresh spec, written from zero after the earlier attempt was discarded.
Twelve decisions settled, 35 anchors verified against main at f8d093f.

The design hangs the durable boundary on codes.revoked rather than on a
session-keyed registry. A rotated refresh token inherits its parent's
code_id, so marking the code marks every present and future descendant:
gap 2 closes structurally with no race to serialize, and retention needs
no defended horizon because the existing code reaper already refuses to
delete a code while any refresh token references it.

Verification departed from the issue in five places, notably that gap 3's
predicate cannot be "no valid session" without breaking the #46
regression guard, and that gap 2's cost estimate assumed a design this
document does not adopt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill's references gained a four-surface documentation sweep and a
follow-ups section after this agreement was sealed. Running both found work
the first pass missed, so sections 1 to 5 are amended with the corrections
marked rather than absorbed. No sealed decision is reversed and section 3
still has zero open items, so the seal holds.

Two decisions added, both settled with the user:

13. The interactive logout path stays out of scope. Tracing which endpoint
    each "End session" button reaches showed that logout_consent.html posts
    back with only a csrf field, so the ordinary logout takes the no-hint
    branch in HandleAccountLogoutPost: it clears the cookie, audits, and
    writes nothing to the database. handleExistingSessionOnLogout is
    reachable only with an id_token_hint. The session row therefore survives
    an ordinary logout with every client attached. That path performs no
    termination at all, so there is nothing for codes.revoked to mark, and
    fixing it would settle #109's per-client versus whole-session model from
    inside an issue about something else. Drafted onto #109 instead.

14. All three end-session confirmation modals gain a line saying what
    termination disconnects, in both catalogs. Three admin console pages
    carry the button and all three funnel into the two endpoints decision 5
    names, so all three inherit the consequence.

Documentation owed now sits in section 2, with the falsified sentence named:
concepts/tokens.mdx says an offline token keeps working once the browser
session is gone, which stays true of expiry and becomes false of explicit
termination. The earlier pass concluded nothing was falsified and had swept
only the docs site.

Section 9 drafts two follow-ups: the residual serialization window decision
12 accepted, which #134 used to hold before it was closed as moot, and the
interactive logout path as a comment for #109.

Seam 9 now states that the logout pinning tests must drive request shapes
#109 is not rewriting, or they encode its defects as intended behaviour.
Seam 10 records that catalog_hygiene_test.go already guards the six new keys
in both directions, so decision 14 needs no new test code.

37 anchors pass, 14 decisions decided, zero open.
Ending a session has to leave something durable behind, and this is the column
that carries it (#129 decision 4). The marker sits on the code rather than on
the session because a rotated refresh token inherits its parent's code_id, so
marking the code marks every present and future descendant of that grant: a
child inserted after the termination committed is born already rejected. A sweep
over the rows that happen to exist cannot do that, which is what ruled out every
timing-based design.

Absence of a session row could not carry the fact instead. The background worker
reaps idle and expired sessions as routine housekeeping, and an offline grant is
designed to survive exactly that, so termination has to write something positive
down.

RevokeCodesBySessionIdentifier carries `revoked = false` in its predicate, which
is load bearing for the count and not only for idempotence: MySQL reports changed
rows rather than matched rows, and the updated_at assignment alone would make an
already-revoked row count as changed, so without the term the same call reports 2
on MySQL and 0 on the other three engines. The audit event added in a later stage
reports that number.

MarkCodeAsUsed gains the same term, so a code revoked between validation and
claiming cannot still be claimed. A false return from it now means no row
transitioned, whether used, revoked or missing, and the comments there said it
meant reuse; they are corrected, including the handler's debug message. Behaviour
is unchanged: that branch already answered a generic invalid_grant and
deliberately never ran the reuse cascade.

Nothing reads the marker yet, so no observable behaviour changes with this commit.

Tests: unit green in all three modules; data green on sqlite, mysql, postgres and
mssql, each engine run separately, covering the sweep's table, the negative
control of an unrelated session, idempotence, the unknown and empty identifiers,
enlistment in the caller's transaction, the failure path returning an error rather
than a benign zero, the revoked-but-unused claim, the dont-update guard, and the
migration's shape, backfill, down and re-apply.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 6 is the plan, eight stages, stage 1 in full and the rest sketched. Section
7 opens the run log with the plan review and stage 1.

The plan review changed the plan in three places, all of them cases where the first
draft had drifted from what the seal already decided. Stage 3 is now an inert helper
nobody calls and stage 4 is a single activation commit carrying both endpoints, both
handler test files, the integration cases, every documentation page and all three
confirmation modals, so no deploy can widen what ending a session revokes while a
modal still describes the narrower behaviour. The two in-flight-ceremony sentences
are held back to the stages that make them true, since documenting a fail-open path
as closed is worse than documenting nothing. Stage 7 is forced to extend
DeleteUsedCodesWithoutRefreshTokens rather than adding a second method.

Section 6 also reverses the first draft's refusal to create the two handler test
files section 1 asked for. Decision 9's contract has no other home: integration can
read audit rows but cannot force the termination transaction to fail, so nothing
there can prove both events are suppressed on failure.

Section 0 gains five rows for the code stage 1 created, below a line marking them as
the run's rather than the seal's.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
may-continue.sh parses a step's status from the numbered line itself, so a status
on a continuation line reads as an untraced step and refuses the gate. The stage 1
steps now carry a short bold title plus the status on the numbered line, with the
detail indented under it, and the stage body gains the as-built note the guard also
requires. Same shape for every later stage.

The as-built note records the two departures: handler_token.go was edited for
comment accuracy although no step listed it, and the code preceded the plan because
the case tables had to be executed before being written down.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 1 added the codes.revoked marker and the sweep that writes it, but
nothing read it. This makes it mean something at both redemption sites.

On the authorization_code path the check reads codeEntity.Revoked and
answers a generic invalid_grant. On the refresh_token path it reads
refreshToken.Code.Revoked, guarded by !isROPCToken since a ROPC token has
code_id NULL and so no grant origin to terminate. Both read data already
loaded, so neither adds a query.

Placement is the security-relevant part and is deliberate on both paths
(decision 7). Neither check joins the user-enabled, generation and expiry
block above: that block runs before client authentication and before PKCE,
so a check placed there tells a presenter holding a stolen code whether the
session was terminated, which is the disclosure #137 exists to close. The
code path also sits after the wasReused return, so #77's containment
cascade is not pre-empted, reuse being the stronger signal that already
revokes everything this check would refuse. The refresh path sits after the
client-ownership check.

The refresh check precedes the typ switch on purpose. That switch's Offline
branch checks only the max lifetime and deliberately never consults the
session, because a session merely expiring must leave an offline grant
working (decision 2), so this is the only gate that reaches an offline
token. It is also what closes gap 2 structurally: a rotated child inherits
its parent's code_id, so it is born already rejected rather than caught by
a sweep.

Tests: TestValidateTokenRequest_RevokedCode, ten subtests over both grant
types plus ROPC. Four are ordering rows that each vary one thing from an
otherwise valid revoked request and name the gate that must answer instead,
so none can pass if the check drifts up into the pre-authentication block.
Two positive controls, one per grant type.

Unit tier green across all three modules. No data or integration tier: this
adds no query and changes no endpoint.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dded

Stage 2 and its four steps flip to Done, with an as-built note naming the
one departure: ten test rows shipped against a plan that specified nine.

Section 7 gains the stage 2 entry. It carries the tiers as re-run at the
gate rather than as quoted from the implementation session, both review
rounds with their model and effort, and round 1's blocking security finding
in full, because the reasoning is the useful part. The planned
missing-secret ordering row was refused by a length check running before
decryption, so it pinned only that the revoked check sits after the
missing-credential return, not that it sits after authentication completes.
The finding was demonstrated by mutation rather than argued, and the
demonstration is recorded.

The entry also records that the stage spanned two driver sessions, and what
the second re-verified from disk before trusting the first: the diff hash
against .review/before.sha, the verdict's request_id, and the tiers.

Section 0 gains three anchor rows below the run's line for the code stage 2
created. check-anchors.sh passes all 45.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TerminateUserSessionTx performs decision 5's three writes in one
transaction it owns and commits: mark every code issued through the
session revoked, sweep the refresh tokens that session's grants issued,
delete the session row. The first write is the one that survives, and
the only one that is a boundary: a refresh token can only descend from a
code and a rotated child inherits its parent's code_id, so marking the
code rejects every present and future descendant of the grant.

The sid-scoped token query is load bearing rather than incidental. It
matches codes.session_identifier through a join, and that join is the
only thing reaching an offline grant's tokens, because an offline refresh
token's own session_identifier is empty. Re-filtering the returned rows
by rt.SessionIdentifier would drop exactly the offline tokens decision 2
exists to revoke, which is gap 1.

Any error returns the zero result rather than a partially populated one:
the code sweep can succeed and the transaction still roll back, and a
caller auditing that count would record a revocation that never
happened. The audit events stay the caller's job, after a successful
return, because AuditLogger.Log takes no transaction.

It deliberately does not advance the user's authentication generation.
Doing so would invalidate every other device that user has, which is the
opposite of what ending one session means, and the reason this issue
exists separately from #106.

Nothing calls the helper and nothing emits the new
terminated_user_session event yet. Stage 4 wires both endpoints and
carries every piece of user-facing material about them in one commit, so
no deploy can perform the broader security action while a page or a
modal still describes the narrower one.

Tests: unit tier green, all three modules. Four new test functions over
10 leaf cases in revocation_test.go, plus the six audit event guards with
the count at 95. Two mutations confirm the suite has teeth: re-filtering
the swept rows by the token's own sid fails three assertions in the
offline case, and populating the result as the helper goes fails four of
the six failure rows. No data or integration tier: no query added, no
endpoint changed.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n refuted

Stage 3's steps flip to Done with the as-built note, and section 7 gains
its entry: what landed, the tiers re-run at the gate, the review and its
one finding, and the three deviations.

Two things the trace has to carry that the code does not. The mutation
that populates the result as the helper goes fails four of the six
failure rows, not the one the implementation session's mailbox note
claimed, so the keep-this row's value is narrower than the plan
predicted: it names the property and asserts the count, rather than being
the only row with teeth. Step 4's table keeps its original wording,
because it is the prediction and editing it would erase the evidence that
the prediction was wrong.

And the stage stalled between sessions because its trace went to
.review/stage3-log.md instead of here. The mailbox does not survive the
next gate, so on the skill's own terms the stage had recorded nothing,
no session ran to ingest round 1, and the driver re-invoked the reviewer
three further times against a byte-identical request. Round 1's verdict
was recovered from .review/drive.log and verified against the code before
being written up.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both explicit "end this session" endpoints now call TerminateUserSessionTx
instead of DeleteUserSession, so ending a session marks the authorization
codes issued through it revoked and sweeps the refresh tokens those grants
produced, offline ones included, in one transaction. Stages 1 to 3 built the
column, the rejections and the helper with nothing calling any of them; this
is the commit where the behaviour changes.

Each handler emits deleted_user_session with its existing payload untouched
and then terminated_user_session with the security detail, both after the
transaction commits and neither on the error path, so an audited termination
can never describe something that rolled back. The admin handler's not-found
gate and the account handler's ownership check still answer before the
transaction opens.

The user-facing material lands in the same commit rather than in a later docs
stage, so no deploy can perform the broader security action while a modal or
a page still describes the narrower one: three confirmation modals in both
locales, and four documentation pages covering what termination revokes, the
distinction from a session merely expiring, and the limit that an offline
grant's access token keeps working until it expires because it carries no
session identifier to check.

Tests: unit green on all three modules, including two new handler test files
covering the audit payloads field by field and their suppression when the
transaction fails, which no higher tier can force. Integration green on all
four engines, adding the headline case at POST /auth/token, its same-user
survivor control, the account ownership pairing, and two cases pinning that
logout still revokes nothing.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The activation stage's as-built note, its mutation evidence and its tier
results were written before the review ran. This adds the review itself:
round 1, gpt-5.6-sol at effort high, all three axes reviewed, zero findings
and zero follow-ups.

A clean verdict is recorded with its scope rather than its conclusion,
because zero findings in a narrow scope reads exactly like zero findings in a
thorough one. So the entry names what the reviewer executed, what it checked
by reading, and why nothing in its unchecked list is something the gate
depended on.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Blocking decision: does the /auth/completed gate require a password, or is OTP enough?

Stage 5 is implemented, its tests are green on every tier it claims, and the Codex review re-ran all of
them. The review then found one thing, on the security axis, and I have confirmed it against the code.
It is a question I am not allowed to answer alone: the run never auto-resolves anything in
authentication or session territory, and never settles a security finding by itself. So stage 5 is
uncommitted and waiting on you.

This is agreement decision 15, and it is the run's first blocking escalation.

What stage 5 built

Decision 6's gate. HandleAuthCompletedGet's no-valid-session branch used to call
StartNewUserSession unconditionally, so a ceremony whose session was ended mid-flight silently
recreated it. Stage 5 gates that on !userReallyAuthenticated, exactly as decision 6 specifies, reading
the AuthenticatedAt field the handler already computed.

The problem

AuthenticatedAt has two writers, not one. handler_auth_pwd.go sets it, and so does
handler_auth_otp.go. So a ceremony that reached OTP by SSO reuse, never touching the password form,
satisfies the gate on OTP alone and recreates the session that was just explicitly terminated.

I verified all five hops rather than taking the reviewer's word for it:

  1. HandleAuthorizeGet's SSO branch copies the session's user and auth state onto the AuthContext and
    leaves AuthenticatedAt nil.
  2. HandleAuthLevel1CompletedGet sends that ceremony to /auth/level2 when the target ACR is higher
    than the session's, which is the ordinary step-up.
  3. HandleAuthLevel2Get sets AuthStateLevel2OTP for level2_mandatory, and for level2_optional
    when the user has OTP. No password is needed to arrive, and no session liveness is re-read.
  4. The session is ended here. HandleAuthOtpPost checks only that the state is
    AuthStateLevel2OTP. It reads no session row, so the termination is invisible to it. On success it
    sets AuthenticatedAt = now and redirects to /auth/completed.
  5. HandleAuthCompletedGet finds no session row, computes userReallyAuthenticated as true, passes
    the gate, and calls StartNewUserSession. HasValidUserSession and UserSessionLoadUser both
    handle the nil session cleanly, so nothing intervenes with a 500.

The sub-case that sets the severity. For a user without OTP on a level2_mandatory client,
HandleAuthOtpGet generates a fresh TOTP secret, renders it into the enrollment page, and stores it in
the http session. The browser is handed the secret it will be tested on. So a holder of a stolen session
cookie can produce a valid code with no password and no second factor of their own, and ending the
session does not stop that ceremony.

Why I cannot just pick an answer

The sealed text disagrees with itself about this exact ceremony, which is why it is yours to settle.

  • Decision 6's supporting facts name both writers and call them "both real authentication". Read
    that way, stage 5 is correct as built and there is nothing to fix.
  • Section 1's departure note says the discriminator is whether this ceremony performed level 1
    authentication, and that pwd/authenticated-at "is the only level 1 writer" of the field. Read that
    way, stage 5 is incomplete, and goal 1's "cannot be undone by anything already in flight" stays false
    for this ceremony.

Note that "the run built what was sealed" is true and still does not dispose of it. A conformance
defence does not answer a security axis: the gate fails open on a reachable path either way.

Options

A. A dedicated level 1 proof on the AuthContext. Recommended.
A new field written only by handler_auth_pwd.go. The gate reads that; AuthenticatedAt keeps its
existing job of refreshing AuthTime after a step-up. Makes the gate mean what section 1 says it means,
in one place, at the line a later reader is looking at.

  • Cost: one field on a shape already persisted as JSON in the cookie, one write, the gate expression,
    two unit rows at seam 6 and one integration case in session_deletion_test.go.
  • Covers any future route that reaches /auth/completed without a password, not only the OTP one.
  • Upgrade wrinkle, small: a ceremony already past the password form when the binary is replaced has the
    field nil, so it gets sent back to login once.

B. A liveness read in the OTP handler.
At the top of HandleAuthOtpPost, AuthenticatedAt == nil means this ceremony did no level 1, so
require the bound session to still resolve before accepting the OTP.

  • Cost: no new field, one session lookup in a handler that does none today.
  • Weaker: the gate keeps reading a field that does not mean what it says, and the reasoning now lives in
    two handlers, so a third writer of AuthenticatedAt would need both updated.

C. Accept it and document the limit.
Defensible on decision 6's literal text, and cheapest. I do not recommend it: the enrollment sub-case
makes it a fail-open reachable with a stolen cookie alone, which is the shape #129 exists to close.

D. Defer to a follow-up issue. Ruled out rather than weighed. Gap 3's first half is what goal 2 and
section 4.4 ask for, and drafting a follow-up for work the agreement owes would narrow this run's scope
and then report success.

What either mistake costs

Choosing A or B when C was right costs one field or one query, plus one extra password entry for an
honest user in the rare race where their session expires mid-step-up. That outcome is arguably correct
anyway: a session created from OTP alone, in a ceremony where no password was entered, is the thing in
question.

Choosing C when A was right leaves explicit termination defeatable by an in-flight ceremony, on a path a
stolen cookie reaches, which is the defect this issue exists to close.

Worth saying plainly: the legitimate step-up is untouched by any option. When the session is still
valid the handler takes the hasValidUserSession branch and never consults this gate. Only the
no-valid-session branch changes. TestSessionDeletedDuringAuthFlow_LoginSucceeds, #46's guard, enters a
password and stays green under A and B.

State of the run

  • Stages 1 to 4 are committed and pushed. The behaviour change has landed.
  • Stage 5 is implemented and uncommitted, deliberately, because the gate expression is the subject
    of this decision and nothing resting on an open decision gets committed. The working tree holds
    handler_auth_completed.go, handler_auth_completed_test.go, handler_auth_otp.go,
    handler_auth_pwd.go and concepts/user-sessions.mdx.
  • Its tiers were green and the reviewer re-ran them: modules on three modules, integration on all four
    engines, 59 anchors, 42 site pages.
  • Stages 6, 7 and 8 have not started. Stage 6 owns the other half of gap 3 and is unaffected by this.

Answer here and the run picks it up: it flips decision 15 to Decided with your reasoning, applies it
inside stage 5, extends the case table, and opens round 2 at the same gate.

Stage 5's code review returned one blocking security finding, confirmed
against the code rather than taken on the reviewer's word: AuthenticatedAt
has two writers, and handler_auth_otp.go is the second one. So a ceremony
that reached OTP by SSO reuse, never touching the password form, satisfies
decision 6's gate and recreates the session that was just terminated. For a
user without OTP on a level2_mandatory client the enrollment page hands the
browser the secret it will be tested on, so a stolen cookie alone reaches it.

The sealed text disagrees with itself about this ceremony. Decision 6 names
both writers and calls them both real authentication; section 1's departure
note says the discriminator is level 1 authentication and that the password
handler is its only writer. That is the user's call, not the run's: nothing
in authentication or session territory is auto-resolved, and no security
finding is settled by the run alone. Recorded as decision 15, Open, with the
four options and a recommendation, and escalated on PR #138.

Stage 5's code stays uncommitted, since the gate expression is the subject of
the open decision. Section 8 is added, empty, because the closing PR comment
reports it and an absent section reads the same as a forgotten one.

Anchors green, 59 rows. No code changed in this commit.

Refs #129
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Let's go with option A.

The user chose option A on PR #138: a dedicated level 1 proof on the
AuthContext rather than reading AuthenticatedAt, which has two writers.
Decision 15 flips to Decided with their words and what B, C and D lost
on, and keeps the escalated question so the answer stays reviewable.

Stage 5's steps are amended where the answer changed them rather than
rewritten, because the run log's first stage 5 entry describes the
earlier version: step 1's predicate, step 2's comment work (which the
answer reverses), step 3's case table (one row replaced, not added),
and a new step 6 for the field and the two assertions that pin who
writes it. Four anchor rows added, one re-swept.

Section 7 gains the continuation entry: what landed, the tiers re-run
in full on the amended tree, and five mutations with disjoint failure
sets, including one at the integration tier showing the round 1 gate
issues a code and recreates the session while #46's guard stays green.

Refs #129
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Applied. Decision 15 is Decided with your words, and stage 5 now carries option A.

What landed: AuthContext.Level1AuthCompleted, a bool written only by handler_auth_pwd.go and
read by the gate in handler_auth_completed.go. AuthenticatedAt keeps its existing job of deciding
whether to refresh the session's AuthTime on a step-up, and handler_auth_otp.go gained a comment
saying it deliberately does not set the new field, with a test assertion pinning that so the asymmetry
does not get tidied away later.

Three comments changed rather than two, and one is a reversal of what the first stage 5 pass did: it had
widened the comments on both AuthenticatedAt writers to say that field also decides whether a session
may be created, which option A makes false. They are back to their original wording, which is accurate
again. The comment that really was falsified is the one at the computation itself, which claimed the
field is "set by the password handler"; it now names both writers.

Tests: the zero-time AuthenticatedAt subtest was replaced rather than kept beside the new one. It
pinned an !IsZero() spelling of a discriminator this gate no longer reads, and the new OTP subtest
catches the same mistake through a shape the code can actually reach. Plus one assertion each in the
existing password and OTP handler subtests to pin who writes the field, and
TestSessionEndedDuringStepUp_OtpAloneDoesNotRecreateTheSession at the integration tier, sitting beside
#46's guard in the same file, varying exactly one thing: whether a password was entered.

Verified on the amended tree: modules green on three modules, integration green on all four engines,
63 anchors resolving. Five mutations with disjoint failure sets, and the one that matters is the round 1
gate restored: at the integration tier it issues a code to the client and recreates the session, while
#46's guard stays green. So the defect reproduces and the fix closes it end to end.

The cost you accepted shows up as expected: a user whose session expires while they are on the OTP
form enters a password once. The legitimate step-up on a live session is untouched, which the
pre-existing re-auth subtest pins.

Stage 5 is still uncommitted, deliberately: the gate changed after round 1 read it, so review round 2 is
opening on the current tree. Stages 6, 7 and 8 follow after that.

leodip and others added 3 commits August 5, 2026 12:56
…ssion

HandleAuthCompletedGet's no-valid-session branch called StartNewUserSession
unconditionally, minting a session from authContext.UserId with no proof that
anyone authenticated. So an SSO ceremony whose session was ended mid-flight
resumed at /auth/completed and silently recreated the session it was riding,
and the code it then issued carried a fresh session identifier that no marker
from stages 1 to 4 could reach: those stages mark and reject grants that
already exist, and this one does not exist yet when termination runs.

The gate is deliberately "this ceremony performed level 1" rather than "no
valid session". The second shape is legitimate and already guarded by
TestSessionDeletedDuringAuthFlow_LoginSucceeds (#46): a session is deleted,
the user starts a fresh ceremony and really does enter a password. Failing
that shape would break a case that must keep working.

The proof is a dedicated Level1AuthCompleted bool on the AuthContext, written
only by the password handler. AuthenticatedAt could not carry it: the OTP
handler sets that field too, so a ceremony that stepped up to OTP by reusing
a session satisfied it without a password, and for a user enrolling on a
level2_mandatory client the browser is handed the secret it will be tested
on, making the bypass reachable with a stolen cookie alone. That was found by
review and answered by the repository owner on PR #138. AuthenticatedAt keeps
its existing job of deciding whether to refresh a live session's AuthTime.

An older cookie decodes the absent bool as false, which sends a ceremony
already past the password form back to login once, and the legitimate step-up
on a live session is untouched because it never reaches this branch.

Tests: unit green on all three modules, with the completed-handler suite at 13
subtests where main has 10, covering the terminated-SSO shape, the OTP-only
shape, the three positive controls that would fail against a gate keyed on the
session alone, and the zero-timestamp row that keeps the neighbouring
AuthTime discriminator honest. One assertion each in the password and OTP
handler suites pins who writes the new field and who must not. Integration
green on all four engines, adding the five-hop OTP step-up ceremony from the
review finding, paired with #46's guard still green.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 read the tree that decision 15's answer produced and returned one
quality finding: the amendment that added the OTP-only row deleted the
zero-timestamp row beside it, and the claim that the re-auth subtest still
covered userReallyAuthenticated's pre-existing !IsZero() term was false, since
that subtest supplies a nonzero timestamp and passes either way. The run's own
round 1 mutation evidence had already demonstrated exactly that and the
amendment carried the claim forward without re-running it.

Resolved inside the stage rather than escalated: the axis is quality, the fix
is one test row with no production code, interface, schema, wire format,
persisted shape or security property involved, and the reviewer, the code and
mutation 2 already agree on what is uncovered, so there was no question left
to ask. The reasoning is written out because the verdict asked for a human.

Section 0 gains test/completed-authtime-zero, step 3's table gains the row and
its amendment note now records the false claim instead of absorbing it, and
the stage is Done with both tiers re-run in the foreground on the committed
tree: modules green at 13 subtests, integration green on all four engines, 64
anchors resolving.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fusal

Stage 6's round 1 review found one blocking conformance defect and it is
confirmed against the code: the new liveness refusal at /auth/issue restarts
level 1 unconditionally, so a prompt=none ceremony whose session ends in the
redirect hop from handlePromptNone is rendered a password form instead of being
returned login_required. HasPromptValue has three production callers and none is
on that path, so nothing between /auth/issue and /auth/pwd knows the request
forbids UI.

Escalated rather than fixed because decision 6 decides this shape rather than
omitting it: it names prompt=none as the second sub-case's other entry, says
restart level 1, and explicitly rejects login_required. Overriding a sealed
rejection in authentication territory is the user's call.

Section 3 gains item 16 as Open with four options and a recommendation, the
header records the halt, section 7 gains the full round 1 record including what
the review found sound, and section 8 is no longer stale. Stage 6's code stays
uncommitted, as stage 5's did on decision 15. One bookkeeping fix rode along:
middleware/session-identifier was cited three times and declared in no anchor
row, so check-anchors now covers it at 72 rows.

Refs #129
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Blocking decision: for prompt=none, does /auth/issue show a login form or return login_required?

Stage 6 is implemented, green on all three tiers, and the Codex review re-ran every one of them
independently on all four engines. It came back with one finding, on the conformance axis, and I have
confirmed it against the code. It contradicts a sealed decision, so I am not allowed to answer it
alone: decision 6 does not merely omit this case, it decides it, and the run never auto-resolves
anything in authentication, session or public-interface territory. So stage 6 is uncommitted and
waiting on you.

This is agreement decision 16, and it is the run's second blocking escalation. Both came out of decision
6, which is worth knowing for next time.

The defect

Stage 6 built the liveness refusal exactly as decision 6 words it: when the ceremony's session
identifier no longer resolves to a session row, refuse to mint a code, set AuthStateRequiresLevel1,
redirect to /auth/level1. For an interactive ceremony sitting on the consent screen that is right, and
it is what the new integration test asserts.

For a prompt=none ceremony it renders a password form, and prompt=none must show no UI at all.

Four hops, each checked against the code rather than reasoned about:

  1. handlePromptNone runs its silent checks, bumps the session, and 302s to /auth/issue. Every one of
    its own failures goes back to the client through redirToClientWithError, and login_required is
    what it sends when its session lookup finds no row.
  2. The session is ended between that 302 and the browser following it.
  3. MiddlewareSessionIdentifier puts the identifier in the request context only when the row exists, so
    /auth/issue reads the empty string. Same shape as the consent-screen case, which is the whole
    reason the check fires at all.
  4. The new branch redirects to /auth/level1, which redirects to /auth/pwd, which renders the form.
    Nothing in between knows the request forbids UI: HasPromptValue has exactly three production
    callers, in handler_authorize.go, handler_auth_completed.go and handler_consent.go, and none is
    on this path.

So a silent-renewal iframe gets a login page and no error at all, and waits for its own timeout instead
of reading login_required. concepts/prompt-parameter.mdx says of none: "Silent authentication. Must
not display any UI. Returns an error if the user is not authenticated or consent is not available." OIDC
Core 3.1.2.1 says the same. The window is narrow, one redirect hop, but it is a window this issue
introduces.

HandleIssueGet already answers a neighbouring case the other way, a few lines above the new check: its
id_token_hint mismatch branch returns login_required through redirToClientWithError and clears the
auth context.

Why I can't just fix it

The fix is about as forced as they come and I recommend it. But decision 6 names prompt=none as the
other entry to this exact sub-case, says restart level 1 there, and explicitly rejects "failing to
the client with login_required or access_denied". Reading that rejection as scoped to interactive
ceremonies would be paraphrasing your decision rather than applying it, and overriding a sealed
rejection in authentication territory is your call, not mine.

Options

A, recommended: return login_required for a prompt=none ceremony, keep the level 1 restart for
every other one.
redirToClientWithError with ErrorLoginRequired then ClearAuthContext, copying
the id_token_hint branch directly above it. No code is minted either way, so the fail-open decision 6
closes stays closed, and the client learns what happened. Decision 6's reasoning survives intact where
it applies: it rejected login_required because it costs an interactive user "a round trip through the
client to arrive at the same password form", and a prompt=none client has no password form to arrive
at and is forbidden from showing one. Cost: one branch, one row in TestHandleIssueGet, one integration
case, cheap because codeFromSameSession already drives prompt=none to the /auth/issue redirect and
follows it on the next line, so the case ends the session between those two lines.

B: restart level 1 for everything, as decision 6 words it, and document the divergence. Keeps stage
6 exactly as built, no code at all. It means editing concepts/prompt-parameter.mdx to carve out an
exception to "Must not display any UI", and it leaves a silent-renewal client with no error to act on.

C: mint the code for prompt=none and let the compensating statement handle it. Ruled out rather
than weighed, and the reason is measured: on this path the identifier is empty, and
RevokeCodeIfSessionGone refuses an empty identifier at entry, so the compensating call errors and the
handler answers 500. A 500 in the iframe is worse than A or B on every axis.

D: return login_required for every ceremony, reversing decision 6's rejection outright. Simpler
than A, one branch and no Prompt read, and /auth/issue then behaves uniformly. It discards a ceremony
that could have completed correctly, which is what decision 6 weighed and rejected, and it reddens the
new consent-screen test, whose assertion is that the ceremony reaches /auth/pwd.

What each mistake costs

Answering A when B was right costs one branch and one test row, and leaves the interactive behaviour you
chose untouched. Answering B when A was right ships an authorization server that renders a login form in
response to a request that forbids one, in a window #129 itself introduces, and the client cannot tell
that from a hang. Both are recoverable in one stage. The asymmetry is that B's cost lands on integrators
and A's lands on this run.

Where things stand

Stages 1 to 5 are committed and pushed. Stage 6's thirteen-file diff is complete and green on modules,
data on all four engines, and integration on all four engines, but uncommitted, exactly as stage 5
waited on decision 15. The agreement is committed and carries decision 16 Open plus the full round 1
record in section 7, including everything the review found sound, so nothing is lost if this session
does not resume.

Reply with the option and I'll apply it inside stage 6, then take it back through review.

The stage 6 round 1 entry said thirteen files, counting this document
alongside the uncommitted stage tree. The tree is twelve, now enumerated
so a resuming session can check it without recounting: a count that
disagrees with git status reads as a lost file.

Refs #129
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Let's go with option A again.

The user answered on PR #138: "Let's go with option A again." A prompt=none
ceremony whose session ends between handlePromptNone's redirect and /auth/issue
is returned login_required rather than restarted into a password form it is
forbidden to display; every other ceremony still restarts level 1, so decision
6's rejection of login_required is scoped rather than reversed.

Decision 16 flips to Decided carrying the user's words and what B, C and D cost.
Stage 6 gains the outcome in step 2, a row in the step 5 and step 7 case tables,
a third docs sentence in step 6, two anchor rows, and a run log entry recording
the re-run tiers and mutations 7 and 8. Section 3 has zero Open items again.

Refs #129
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Applied. Decision 16 is Decided with your words, and stage 6 now carries option A.

What landed: one branch at the head of the existing refusal in HandleIssueGet. When the
ceremony's session is gone and it carries prompt=none, it goes back to the client with
login_required through redirToClientWithError and the auth context is cleared, copying the
id_token_hint branch a few lines above it in the same function. Every other ceremony still restarts
level 1, so decision 6's rejection of login_required ends up scoped rather than reversed: its stated
reason was that failing to the client costs a user "a round trip through the client to arrive at the
same password form", and a prompt=none client has no password form to arrive at.

One judgement inside your answer, recorded rather than made silently. The error description is
handlePromptNone's own wording for this condition, "User authentication is required", rather than
something naming the mid-flight termination. The client cannot act differently on the two cases, and a
distinct description would only tell a caller which internal hop refused it. It has a useful side
effect: concepts/prompt-parameter.mdx stays accurate unedited, down to the error_description in
its own worked example, so the docs carve-out option B would have cost is avoided rather than just
deferred.

Tests. One row at TestHandleIssueGet, bringing it to 8 where main has 3, and
TestSessionEndedBeforeIssue_PromptNoneGetsLoginRequired at the integration tier, the fourth ceremony
in session_deletion_test.go. It uses createSessionWithAcrLevel1 for the live session and ends it
between assertRedirect and loadPage, which is the entire window a silent ceremony has, since it
never waits for a person.

Docs. A third sentence in concepts/user-sessions.mdx, next to the two that both say "sent back
to the login page": an application renewing quietly with prompt=none is told rather than shown, and
gets login_required back. Without it a reader generalizes the login-page outcome to a background
renewal, which is exactly the outcome your answer rejected for that shape.

Verified on the amended tree, re-run in full rather than narrowed to the change: modules green on
three modules; data green on all four engines run separately; integration green on all four engines,
889 passing test lines per engine and zero failures, with all four of the file's ceremonies passing
once per engine; 74 anchors resolving; npm run build at 42 pages.

Two mutations, and they are the pair that matters here. Removing the new branch, which is stage 6
exactly as the review read it and is your option B, fails precisely the new unit row and the new
integration case while the three sibling ceremonies stay green. Making the branch unconditional, which
is option D, fails the two interactive unit rows and TestSessionEndedOnConsentScreen_NoCodeIsIssued
while the new case passes. Disjoint failure sets, so the branch is pinned as keyed on the prompt
rather than as replacing the refusal, and D's cost is measured rather than predicted.

Stage 6 is still uncommitted, deliberately: the branch the review found missing is now present, so
round 1's verdict no longer describes this tree. Review round 2 is opening on it, and the code commit
follows that. Stages 7 and 8 follow after.

leodip and others added 5 commits August 5, 2026 14:51
…thing

Round 2 found the prompt=none refusal clearing the auth context after
redirToClientWithError had already committed the response, so the
Set-Cookie carrying the clear was dropped and the browser kept a context
in ready_to_issue_code. Confirmed against the source and fixed inside the
stage rather than escalated: unlike decision 16, this is not a question
the agreement answered the other way, it is decision 16's own answer
failing to take effect, and the ordering is forced by the same function's
success path.

Records the finding, the fix, mutation 9 at both tiers, three new anchor
rows, the extended case tables, and the reasoning for a third round. Also
drafts follow-up 4: the same inversion sits at seven pre-existing call
sites, which is where #129's branch copied it from.

The stage tree stays uncommitted pending round 3.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gap 3's remaining exposure was a ceremony that passed /auth/completed while
its session was alive, sat on the consent screen, and reached /auth/issue
after the session was ended. The code minted there is brand new, so the
marker termination writes onto existing codes cannot reach it.

HandleIssueGet now checks liveness after the implicit-flow branch and before
creating the code. The shape that case actually arrives in is an EMPTY
session identifier, not a stale one: MiddlewareSessionIdentifier puts the
identifier in the request context only when the row exists. That is not
inert, because grantIsOffline reads an empty identifier as an offline grant
on its own, so a code issued here would yield a refresh token good for up to
RefreshTokenOfflineMaxLifetimeInSeconds whether or not offline_access was
asked for.

The refusal has two outcomes. An interactive ceremony restarts level 1, per
decision 6. A prompt=none ceremony is returned login_required instead, per
decision 16, because /auth/level1 leads to a password form and that request
forbids any UI; the description matches handlePromptNone's own wording for
this condition, so the client cannot tell the two hops apart. The auth
context is cleared before the client response is written, since
ClearAuthContext persists through a Set-Cookie and redirToClientWithError
commits the response in every mode.

RevokeCodeIfSessionGone, new on all four engines, compensates for the read
followed by an insert: one statement that marks the code when the session is
already gone, refusing an empty identifier or a zero id at entry. Termination
marks codes that exist when it sweeps, this marks a code whose session was
gone when it landed, and the interleaving escaping both is recorded as a
residual rather than closed here.

Tests: unit on three modules, data on mysql, postgres, mssql and sqlite,
integration on all four engines, all green. Three review rounds, the last
clean.

Refs #129
Records round 3's clean verdict and what it covered, since a clean verdict is
worth only what it checked: an independent scratch reproduction against the
real CookieStore showing the clear now commits in all three response modes,
the refusal's own behaviour intact, both new assertions biting for the right
reason, and follow-up 4 correctly scoped out.

Stage 6 flips to Done and the header now describes the committed state, which
is the one bookkeeping point the reviewer raised. Also records the red run
that preceded the green one: the tier script was piped into head, kept running
in the container and held port 19090, so the next run contended with it.

Refs #129
Ending a session marks every authorization code of that session revoked.
A code revoked while it was still unredeemed has used = false, and the
existing reaper filters used = true, so those rows would accumulate
forever. That unbounded growth is the defect that moved the marker off a
session-keyed registry in the first place, so leaving it would reintroduce
what decision 4 rejected.

DeleteUsedCodesWithoutRefreshTokens gains a second branch rather than a
second method: one statement, one shared created_at cutoff, no interface
signature change, no migration. Both classes are bounded by the same
cutoff, so stating it once keeps the two copies from drifting.

Two things about the predicate are load bearing rather than formatting.
The NOT IN subquery stays inside the used branch, because ROPC refresh
tokens carry code_id = NULL and x NOT IN (..., NULL) is UNKNOWN: hoisting
it beside the cutoff would make the whole predicate UNKNOWN and ship the
extension inert on any deployment that has ever issued a ROPC token. And
used = false is what keeps the sweep away from a code with a live
descendant, where the stake is higher than losing a marker, since
fk_refresh_tokens_code is ON DELETE CASCADE and deleting the code would
delete the very refresh token the marker exists to reject.

Nothing a user can observe changes. A code this reaps was already
unredeemable, and redemption answers a code it cannot find with the same
generic invalid_grant it answers a revoked one with.

Tests: two data-tier tests on all four engines, one carrying the class
table and one carrying the ROPC NULL row alone, because a NULL code_id
anywhere changes what the used branch can prove. Data green on mysql,
postgres, mssql and sqlite; modules green on all three modules.
Integration not applicable: no endpoint changes, and these rows are
unreachable from any endpoint by the time they are reaped.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 1 came back clean: all three axes reviewed, zero findings, zero
follow-ups. The entry records what it ran and what it answered question by
question, because zero findings in a narrow scope reads exactly like zero
findings in a thorough one.

Two things worth the space. The reviewer built the statement independently
on all four flavors rather than reading the Go, and this session re-derived
it again before committing, since a wrongly grouped subquery is the one
failure in this stage that leaves a green suite. And .review/before.sha was
stale from stage 5, so the reviewer's no-edit claim rests on file mtimes
instead of the usual hash comparison; that substitution is recorded rather
than passed over.

Stage 7 flips to Done and the header follows. Next: stage 8, gap 2's
evidence, the last stage.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
leodip added 2 commits August 5, 2026 16:19
…sable

Gap 2 of #129 is the window inside rotation: the presented refresh token is
claimed and its replacement inserted as two separate commits, so a refresh that
validated before a termination swept the grant inserts its child after the sweep
committed. For an offline grant nothing else invalidates that child, because the
Offline branch of ValidateTokenRequest checks only the max lifetime and
deliberately never consults the session.

Decision 4 closed it structurally in stages 1 and 2 rather than by serializing
anything: a rotated child inherits its parent's code_id, so marking the code
marks every descendant, and the child is born already rejected because the fact
predates its existence. Decision 1 asks for the proof before the issue closes,
and this is that proof and no production line.

TestToken_Refresh_ChildOfATerminatedGrantIsBornRejected checks the claim both
ways round on one offline grant terminated through the real admin DELETE. The
child that existed when the sweep ran must be refused by the marker rather than
by its own revoked row, which the exact error_description distinguishes, since
the marker is read in the validator ahead of the handler's revoked-row branch.
Then the same child with its own revoked column cleared, which is byte for byte
the row state a child inserted after the sweep has: server-minted JWT, row
written by rotation, code marked by the real endpoint, session really gone, and
the single cleared column the only thing the interleaving decides. That is the
case that would hand out a working token without the marker.

TestToken_Refresh_RacingATermination_LeavesNoUsableDescendant drives the window
for real, eight presentations and the DELETE released together, and asserts the
unconditional invariant: at most one replacement, and every replacement plus the
pre-race parent refused afterwards. It claims nothing about which interleaving
occurred and logs what it got, so a vacuous run says so. Measured, every engine
produced one replacement, and on mssql and sqlite it was still live in storage,
which is gap 2's own interleaving reached end to end.

Two mutations measured on the real code and reverted. With the marker read
dropped, and separately with RevokeCodesBySessionIdentifier dropped from the
termination helper, stage 4's headline endpoint case and stage 6's consent
ceremony both stayed green and only these two tests failed, answering the live
child 200 with a working access token. That is the argument for the stage.

Tests: integration green on all four engines, exit 0, 4768 passing test lines,
zero FAIL, both new tests passing once per engine. Modules and data not run and
not claimed: the one changed file is in a package neither tier compiles.

Refs #129
Stage 8 flipped to Done with its review recorded. Round 1 was clean on all three
axes with zero findings, zero follow-ups and zero bookkeeping, and the reviewed
tree is provably the committed tree: .review/before.sha and git diff | sha256sum
both read f51d8c916c26, the hash the request named.

The entry records what the reviewer ran rather than that it agreed, its answer to
each of the request's five questions, and one honesty note: its captured log
truncates 3.5MB in the middle, so the per-engine race reading stands on this
run's own measurement, not on the reviewer's agreement with it. What the capture
does prove is a run spanning mysql through sqlite with zero FAIL and the
harness's All tests completed successfully.

Remaining: the closing pass, the full suite across every tier, sections 8 and 9
onto the pull request, and gh pr ready.

Refs #129
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Deferred decisions: none

The run deferred nothing, so there is no list of assumptions here to check. That is the first thing to
read on this PR and it is worth one paragraph of explanation rather than a bare "n/a", because an empty
section reads the same as a forgotten one.

/leo-run may settle a question on its own only when it is derivable from a sealed decision, forced by
the code, or local and reversible, and it may never settle one in authentication, token, session,
cryptography, schema or public-interface territory whatever the demonstration looks like. Every question
this run met was in that forbidden territory, so nothing was available to defer on a stated assumption.
Two of them stopped the run and came to you instead:

Decision 15, raised at stage 5's review gate. Decision 6 fixed the gate as
!hasValidUserSession && !userReallyAuthenticated, and stage 5 shipped exactly that. The review found
AuthenticatedAt has two writers, not one: the OTP handler sets it too, so a ceremony that reached
level 2 by SSO reuse and never touched a password form satisfied the gate on OTP alone and recreated the
session that had just been terminated. Worse in the enrollment sub-case, where the server hands the
browser the secret it is about to be tested on, so a stolen session cookie alone was enough. You
answered option A on 2026-08-05: a dedicated level 1 proof on the AuthContext, written only by the
password handler, with AuthenticatedAt keeping its existing job. Built as answered, at
authcontext/level1-proof and completed/level1-restart.

Decision 16, raised at stage 6's review gate. Stage 6 refuses to mint a code when the ceremony's
session is gone, and restarts level 1. For a prompt=none ceremony that restart is a contradiction: the
specification forbids showing that request a login form. You answered option A on 2026-08-05: return
login_required to the client for prompt=none, and keep the level 1 restart for every other ceremony.
Built as answered, at issue/prompt-none-refusal.

Both answers are quoted into section 3 of the agreement with the options that lost, and both are covered
by tests named in section 6.

Two escalations on a change this size is inside the normal range, and both were the same decision
(6) being harder than the interview found it. Section 3 records that, because the judgement belongs to
the next planning pass rather than to this run.

Judgement calls the run did make on its own are not hidden in this comment: each is recorded in its
stage's run-log entry in section 7 with the demonstration that made it forced, mostly test-shape and
naming choices plus the two documentation sweeps that found pages needing edits the plan had missed.

@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Follow-ups the run drafted, four, none filed

Each is a real defect verified against the code, each is outside this change under section 2 of the
agreement, and none is a way to avoid work #129 owed. Drafting is the run's job and filing is yours, so
below is everything needed to file each one, or to drop it.

The duplicate searches were re-run just now against the 33 open issues, since the tracker may have moved
while the run was working: nothing new covers any of the four. #131 and #132 are still the rotation
residuals and still do not cover code issuance, and there is still nothing on amr accuracy or on
ClearAuthContext ordering.

Full reasoning for each, including what was verified and why it is out of scope, is in section 9 of the
agreement.


1. A code insert can still land between a termination's UPDATE and its COMMIT

This is decision 12's accepted residual, and it is the one to read if you read only one. Termination
marks the codes that exist, and /auth/issue marks its own code when the session is already gone, so
the two sweepers cover each other. One interleaving escapes both: an insert committing after the
termination's sweep has read codes but before that transaction commits, where the compensating
statement's NOT EXISTS still sees the session row. What survives is an offline refresh token, for up
to RefreshTokenOfflineMaxLifetimeInSeconds. It needs the same missing primitive as #131 and #132, a
portable way to order a write against a sweep across all four engines, which is why it was accepted
rather than closed here.

gh issue create \
  --title "Security: a code insert can land between a termination's UPDATE and its COMMIT, escaping both marks" \
  --label bug --label security --label go \
  --body "$(cat <<'EOF'
#129 marks a terminated session's authorization codes revoked, and `/auth/issue` runs a compensating `UPDATE ... WHERE NOT EXISTS (SELECT 1 FROM user_sessions ...)` immediately after inserting a code, so a code created after the termination is marked even when the termination could not see it.

The remaining window is an insert committing after the termination's `UPDATE` has read `codes` and before that transaction commits: the compensating statement still observes the session as present, so the code carries no marker, and redeeming it yields an offline refresh token good for up to the configured offline maximum lifetime.

Closing it needs issuance ordered against the revocation sweep, which is the same missing primitive as #131 and #132. Scope the three together.
EOF
)"

2. Add to #109: the interactive logout path writes nothing to the database

A comment rather than a new issue, because #109 already owns this surface and its divergence B is the
same asymmetry one path over. Found while tracing which endpoint each "End session" button reaches, and
it is why decision 13 leaves logout alone.

gh issue comment 109 --body "$(cat <<'EOF'
Divergence B notes that the cookie is wiped unconditionally while the database teardown is per-client. There is a path where the teardown does not happen at all. `POST /auth/logout` without an `id_token_hint` clears the cookie session, writes an `AuditLogout` entry, redirects, and never touches `user_sessions`. Since `logout_consent.html` posts back carrying only a csrf field, that is the path taken by every logout a person performs in a browser, and by the admin console's account menu.

The row therefore survives with every client still attached. Session-bound refresh tokens keep working, access tokens keep passing `RequireValidSession`, and the row is orphaned from the browser because the cookie no longer carries the session identifier. The user still sees it listed as an active session on that device and can only end it with the "End session" button.

Whatever B decides about per-client versus whole-session teardown, this path should reach the same code as the `id_token_hint` path rather than bypassing it.

Found while building #129, which deliberately does not touch logout (its decision 13).
EOF
)"

3. A session-less completion inherits the terminated session's amr

Pre-existing, and #129 neither introduces nor widens it: before stage 5 the same stale value reached a
session created with no authentication at all, so the gate strictly improved it. Re-checked after
decision 15's answer and it stands unchanged.

gh issue create \
  --title "Security: a session-less completion inherits the previous session's amr, claiming otp that did not happen" \
  --label bug --label security --label go \
  --body "$(cat <<'EOF'
An authorization ceremony that reuses an existing session copies that session's `AuthMethods` onto its `AuthContext` in `HandleAuthorizeGet`, and nothing clears the copy if the session stops backing the ceremony. `HandleAuthCompletedGet`'s no-valid-session branch then passes the stale value to `StartNewUserSession`, and `HandleIssueGet` stamps it onto the authorization code, so it reaches the token as `amr`.

The result is a session row, and a token, recording `otp` for an authentication in which only a password was verified. `acr` is unaffected: `SetAcrLevel` recomputes it from the target when there is no session, so the two claims disagree, and a relying party trusting `amr` over `acr` reads a second factor that did not happen in that ceremony.

Reproduce with a user who has OTP enabled: authenticate at level 2 for one client, then send an authorization request from a client whose default ACR is level 1, and end the session while that request is in flight. The ceremony restarts at level 1 (#129 decision 6), the user enters a password, and the new session records `pwd otp`.

Clearing the inherited methods when the ceremony no longer has the session it inherited them from looks like the fix, but it is a claim-semantics change and wants its own decision.

Pre-existing rather than introduced by #129: before that change the same stale value reached a session created with no authentication at all.
EOF
)"

4. Every error response to a client clears the auth context after the response is committed

Seven sites, each read rather than inferred. #129 wrote one of them the wrong way round by copying the
neighbour, and fixing it inside stage 6 is the evidence this behaves as described: with the calls
reversed, a replayed /auth/issue on the same cookie jar answered login_required a second time from a
context still reading ready_to_issue_code.

gh issue create \
  --title "ClearAuthContext is called after the client response is committed in seven handlers, so the clear reaches nothing" \
  --label bug --label security --label go \
  --body "$(cat <<'EOF'
`AuthHelper.ClearAuthContext` deletes the auth context from the cookie session and persists that deletion through `sessionStore.Save(r, w, sess)`, which writes a `Set-Cookie` header. Seven handlers call it immediately *after* `redirToClientWithError`, which has already committed the response: `http.Redirect` for the query and fragment response modes, and `t.Execute(w, m)` for `form_post`. Go snapshots the header map when the status line is written, so the `Set-Cookie` is dropped and the browser keeps the auth context.

The sites are `HandleAuthorizeGet`'s validation-error closure and `handlePromptNone`'s `redirectWithError` closure in `handler_authorize.go`; the disabled-user and no-authorized-scopes refusals in `handler_auth_completed.go`; both consent-denied refusals in `handler_consent.go`; and the `id_token_hint` subject mismatch in `HandleIssueGet`. The success paths in the same files do it in the right order, clearing before writing the response, so this reads as a copied idiom rather than an intended difference.

No bypass is claimed for any single site: replaying a retained ceremony re-runs the same checks. The defect is that a cleanup written seven times never happens, and that the `id_token_hint` site leaves the browser holding a context in `ready_to_issue_code`, the state that mints codes.

The fix is to clear before writing the response at each site, returning a 500 if the clear fails rather than committing a client response that cannot be recorded. A mock expectation cannot catch a regression here, since it proves only that the method was called; the assertion has to read the committed response, either `httptest.ResponseRecorder.Result()` at the unit tier or a second request on the same cookie jar at the integration tier.

Found while building #129, whose own `prompt=none` refusal was written the same way round and fixed inside that change, which is where the demonstration above comes from.
EOF
)"

The guard answered DONE at stage 8's gate, so this is the run's closing pass
written into section 7 rather than left in the terminal.

The full suite ran on the committed tree, every tier, exit 0, zero FAIL and zero
panics: modules across all three modules, then data and integration each on
mysql, postgres, mssql and sqlite. The race's per-engine reading reproduced
exactly, the third independent run to agree with the stage 8 table: mysql and
postgres land the replacement before the sweep, mssql and sqlite land it still
live, which is gap 2's own interleaving.

Sections 8 and 9 are posted to PR #138 as two comments, the deferred decisions
(none, with what came to the user instead) and the four drafted follow-ups with
the gh commands that file them. Their duplicate searches were re-run at the close
against the 33 currently open issues and all four still stand. Nothing was filed.

The entry also records what the run cost: 13 review rounds, 16 mutations, two
blocking escalations both belonging to decision 6.

Refs #129
@leodip
leodip marked this pull request as ready for review August 5, 2026 19:30
@leodip

leodip commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

The four drafted follow-ups are filed

Re-verified against the code first, since the drafts were written during the run. Three went in as
issues, one as a comment, and two bodies changed on the way. The PR description now carries this in a
Follow-ups filed section, and its three dangling "follow-up N" references point at real numbers.

Drafted as Filed as Change on the way in
1, code insert between a termination's UPDATE and its COMMIT #139 Corrected. The draft asserted the window uniformly. It is open on PostgreSQL only; MySQL, SQL Server and SQLite close it incidentally, by InnoDB next-key locks over idx_codes_session_identifier, by a blocking locking-READ-COMMITTED read on the uncommitted DELETE, and by writer serialization. Reframed as "correctness rests on three engines' defaults by accident", the standard #132's criterion 4 already sets. Without this, a reader reproducing on sqlite concludes the issue is wrong
2, the interactive logout path writes nothing comment on #109 Two claims were wrong and are removed. The admin console's account menu does not take the no-hint path: it mints a signed id_token_hint via /api/v1/account/logout-request, so it lands on doLogoutWithIdToken and does tear down. And no first-party UI reaches the no-hint POST at all, since the auth server has no logout link of its own. Reachable callers are an RP omitting id_token_hint, which is conforming, and a direct request. The defect itself stands
3, a session-less completion inherits the terminated session's amr #140 Substance unchanged, severity stated as low. The narrow reachability the draft claimed was confirmed rather than assumed: the AuthMethods copy sits inside HandleAuthorizeGet's hasValidUserSession block, so an idle-expired session leaks nothing and it really does take a mid-flight termination
4, ClearAuthContext after the response is committed #141 All seven sites re-read, as were the two success paths that order it correctly. Gained a second defect on the same lines: two of the seven also fall through after httpHelper.InternalServerError instead of returning, so a failed redirect writes a 500 and keeps going

Duplicates checked against the open and closed tracker. #139 is distinct from #131 and #132, which
are the rotation residuals: session-scoped code issuance against a revocation sweep, versus a
user-scoped generation sweep and a family-scoped containment cascade. Different triggers, different
row sets. #134 held #139's previous shape and was closed as moot when decision 4 moved the marker onto
codes.revoked, so nothing tracked it. Nothing tracked #140 or #141 at all.

Still open, and it is yours to call. Section 9 of the agreement still reads Status: **Drafted**
on all four, and still carries the two claims corrected above. That file is part of this PR, so those
go into the merged history as written. I left it alone because the run log's convention is to record
rather than revise, which is why stage 3's wrong prediction is still sitting there with the
measurement beside it. The consistent fix is to append the corrections and the issue numbers under
each entry rather than edit the drafts. Say the word and I will.

@leodip
leodip merged commit a07c75e into main Aug 5, 2026
1 check passed
leodip added a commit that referenced this pull request Aug 5, 2026
Stage 5's code review returned one blocking security finding, confirmed
against the code rather than taken on the reviewer's word: AuthenticatedAt
has two writers, and handler_auth_otp.go is the second one. So a ceremony
that reached OTP by SSO reuse, never touching the password form, satisfies
decision 6's gate and recreates the session that was just terminated. For a
user without OTP on a level2_mandatory client the enrollment page hands the
browser the secret it will be tested on, so a stolen cookie alone reaches it.

The sealed text disagrees with itself about this ceremony. Decision 6 names
both writers and calls them both real authentication; section 1's departure
note says the discriminator is level 1 authentication and that the password
handler is its only writer. That is the user's call, not the run's: nothing
in authentication or session territory is auto-resolved, and no security
finding is settled by the run alone. Recorded as decision 15, Open, with the
four options and a recommendation, and escalated on PR #138.

Stage 5's code stays uncommitted, since the gate expression is the subject of
the open decision. Section 8 is added, empty, because the closing PR comment
reports it and an absent section reads the same as a forgotten one.

Anchors green, 59 rows. No code changed in this commit.

Refs #129
leodip added a commit that referenced this pull request Aug 5, 2026
The user chose option A on PR #138: a dedicated level 1 proof on the
AuthContext rather than reading AuthenticatedAt, which has two writers.
Decision 15 flips to Decided with their words and what B, C and D lost
on, and keeps the escalated question so the answer stays reviewable.

Stage 5's steps are amended where the answer changed them rather than
rewritten, because the run log's first stage 5 entry describes the
earlier version: step 1's predicate, step 2's comment work (which the
answer reverses), step 3's case table (one row replaced, not added),
and a new step 6 for the field and the two assertions that pin who
writes it. Four anchor rows added, one re-swept.

Section 7 gains the continuation entry: what landed, the tiers re-run
in full on the amended tree, and five mutations with disjoint failure
sets, including one at the integration tier showing the round 1 gate
issues a code and recreates the session while #46's guard stays green.

Refs #129
leodip added a commit that referenced this pull request Aug 5, 2026
…ssion

HandleAuthCompletedGet's no-valid-session branch called StartNewUserSession
unconditionally, minting a session from authContext.UserId with no proof that
anyone authenticated. So an SSO ceremony whose session was ended mid-flight
resumed at /auth/completed and silently recreated the session it was riding,
and the code it then issued carried a fresh session identifier that no marker
from stages 1 to 4 could reach: those stages mark and reject grants that
already exist, and this one does not exist yet when termination runs.

The gate is deliberately "this ceremony performed level 1" rather than "no
valid session". The second shape is legitimate and already guarded by
TestSessionDeletedDuringAuthFlow_LoginSucceeds (#46): a session is deleted,
the user starts a fresh ceremony and really does enter a password. Failing
that shape would break a case that must keep working.

The proof is a dedicated Level1AuthCompleted bool on the AuthContext, written
only by the password handler. AuthenticatedAt could not carry it: the OTP
handler sets that field too, so a ceremony that stepped up to OTP by reusing
a session satisfied it without a password, and for a user enrolling on a
level2_mandatory client the browser is handed the secret it will be tested
on, making the bypass reachable with a stolen cookie alone. That was found by
review and answered by the repository owner on PR #138. AuthenticatedAt keeps
its existing job of deciding whether to refresh a live session's AuthTime.

An older cookie decodes the absent bool as false, which sends a ceremony
already past the password form back to login once, and the legitimate step-up
on a live session is untouched because it never reaches this branch.

Tests: unit green on all three modules, with the completed-handler suite at 13
subtests where main has 10, covering the terminated-SSO shape, the OTP-only
shape, the three positive controls that would fail against a gate keyed on the
session alone, and the zero-timestamp row that keeps the neighbouring
AuthTime discriminator honest. One assertion each in the password and OTP
handler suites pins who writes the new field and who must not. Integration
green on all four engines, adding the five-hop OTP step-up ceremony from the
review finding, paired with #46's guard still green.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
leodip added a commit that referenced this pull request Aug 5, 2026
The user answered on PR #138: "Let's go with option A again." A prompt=none
ceremony whose session ends between handlePromptNone's redirect and /auth/issue
is returned login_required rather than restarted into a password form it is
forbidden to display; every other ceremony still restarts level 1, so decision
6's rejection of login_required is scoped rather than reversed.

Decision 16 flips to Decided carrying the user's words and what B, C and D cost.
Stage 6 gains the outcome in step 2, a row in the step 5 and step 7 case tables,
a third docs sentence in step 6, two anchor rows, and a run log entry recording
the re-run tiers and mutations 7 and 8. Section 3 has zero Open items again.

Refs #129
@leodip
leodip deleted the issue-129-durable-session-termination branch August 5, 2026 21:44
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.

Security: ending a user session does not durably cut off access (offline grants survive, and an in-flight ceremony recreates the session)

1 participant