Skip to content
This repository was archived by the owner on Oct 5, 2026. It is now read-only.

fix(auth-core): make the fixation revoke fire on the form_post path (#36) - #48

Merged
ParallelEntrepreneur merged 1 commit into
mainfrom
fix/fixation-revoke-form-post
Sep 8, 2026
Merged

ParallelEntrepreneur merged 1 commit into
mainfrom
fix/fixation-revoke-form-post

Conversation

@ParallelEntrepreneur

Copy link
Copy Markdown
Contributor

Closes #36. #44 documented this gap; this closes it, using option 1 from the issue — the one it called probably right.

The defect

issue revokes the session the browser was carrying before minting a new one, and it identified that session by the raw cookie the request presented:

if let Some(presented) = login.presented_cookie
    && let Some(old) = store::session_by_token_hash(db, &sha256_raw(presented.as_bytes())).await?

On Apple's form_post callback the session cookie is SameSite=Lax and is simply not sent with a cross-site POST. presented_cookie was therefore always None there, and the revoke could never fire for that provider — the superseded row stayed live server-side until it expired.

The fix

  • Login gains presented_session_id, used when presented_cookie is None. The cookie stays the stronger evidence and wins when both are present.
  • /start — which is same-site, so the cookie arrives there — seals the session id into the signed flow next to the user id it already sealed. The callback hands it back.

The id, not the value, and that is the design rather than an implementation detail: the value is a bearer credential, and a second copy of it inside another cookie widens its exposure for nothing. An id is useless without the row.

Breaking every call site was deliberate

Adding the field breaks all thirteen Login { .. } initializers rather than defaulting to None. Whether a login can name the session it replaces is a decision each path should make, and the compiler now forces it. The four same-site production paths — password login and registration, magic-link consume, passkey login — pass None with a one-line reason: their cookie arrives, so the stronger branch already handles them.

The test, and both ways it fails

My first attempt at this test passed vacuously in the wrong direction: I built a scenario that returned 200 OK without ever reaching issue, because the linking rules answered with a confirm-link page. Rewritten on the path that actually issues — two Apple sign-ins, the first session carried into the second /start — it asserts the old cookie no longer validates and that its row is marked revoked_at rather than merely orphaned.

I then broke each half of the chain and confirmed it catches both:

Broken Result
issue ignores presented_session_id FAILED — "the fixation revoke did not fire"
/start stops sealing the id FAILED — same assertion

Meta

Gets the same wiring for symmetry, but its callback is a GET redirect, so its session cookie already arrives and the cookie branch wins. This is a fallback there that should not normally fire — I have not claimed it as a fix for Meta.

ADR 0102

Gains a dated update note. The paragraph describing the gap is left intact: it is an accurate record of the state between #35 and now, and rewriting it would erase why the decision was deferred.

397 tests, clippy --all-targets --all-features -D warnings, fmt, wasm32 workspace build.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8K9TBtmnf29dV6uEP5oM3

)

`issue` revokes the session the browser was carrying before minting a
new one, and it identified that session by the raw cookie the request
presented. On Apple's `form_post` callback the session cookie is
`SameSite=Lax` and is simply not sent with a cross-site POST, so
`presented_cookie` was always `None` and the revoke could never fire for
that provider. #44 documented the gap; this closes it.

Option 1 from the issue, which it called probably right:

- `Login` gains `presented_session_id`, used when `presented_cookie` is
  `None`. The cookie stays the stronger evidence and wins when both are
  present.
- `/start` — which IS same-site, so the cookie arrives there — seals the
  session **id** into the signed flow next to the user id it already
  sealed, and the callback hands it back.

The id, not the value, and that is the point of the design: the value is
a bearer credential and a second copy of it inside another cookie widens
its exposure for nothing, while an id is useless without the row.

Adding the field to `Login` deliberately breaks all thirteen call sites
rather than defaulting: whether a login can name the session it replaces
is a decision each path should make. The four same-site paths (password
login and registration, magic-link consume, passkey login) pass `None`
and say why — their cookie arrives.

One test, on the path that actually reaches `issue`: two Apple sign-ins,
the first session carried into the second `/start`, asserting the old
cookie no longer validates and its row is marked revoked rather than
merely orphaned. Verified it fails with either half of the chain broken
— the core ignoring the id, and `/start` not sealing it.

Meta gets the same wiring for symmetry, but its callback is a GET
redirect, so its cookie already arrives and the cookie branch wins
there; this is a fallback that should not normally fire.

ADR 0102 gains an update note. The paragraph describing the gap is left
intact: it is an accurate record of the state between #35 and now.

397 tests, clippy -D warnings, fmt, wasm32 workspace build.

Closes #36.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8K9TBtmnf29dV6uEP5oM3
@ParallelEntrepreneur
ParallelEntrepreneur merged commit d5d2897 into main Sep 8, 2026
7 checks passed
@ParallelEntrepreneur
ParallelEntrepreneur deleted the fix/fixation-revoke-form-post branch September 8, 2026 11:15
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session fixation revoke does not fire on the form_post callback path

1 participant