Skip to content

fix: spend the claim challenge once across the widget's history moves - #1278

Merged
peintnermax merged 3 commits into
mainfrom
fix/claim-page-double-spend
Sep 22, 2026
Merged

peintnermax merged 3 commits into
mainfrom
fix/claim-page-double-spend

Conversation

@peintnermax

Copy link
Copy Markdown
Member

Summary

  • The claim page could spend its single-use challenge twice — once in the document hosting the sign-in widget and once in the document the widget navigated to — so the second claim/complete answered 409 and the page told the developer their project was "Already claimed". This is what failed cli-journey-e2e claim.spec.ts on feat(console): branding settings beside a live login preview #1247 (run 35725532364), and it is the same symptom feat: key cache #1212 saw; feat: key cache #1212's globalThis gate covers remounts within one document, and this one is two documents.
  • Cause, from the Playwright trace: on the terminal step <zitadel-login> retires its back-gesture sentinel (ADR 022) with history.back(). That popstate is a navigation to the console router, which reloads the claim route's loader; by then sessions/exchange has set the cookie, so the loader finds a session, the page swaps the widget for CompleteClaim and POSTs claim/complete — 2 ms after location.assign had already requested the next document. The browser aborts the request on unload, the server has already completed the claim, and the next document spends again. It's a race against the next document's HTML response, which is why it shows up on a loaded CI runner (--concurrency 5) and not locally.
  • Two independent guards, each with a unit test that fails without it:
    • packages/components — when the terminal step is about to navigate to post-sign-in-url (this.completing), the sentinel is retired in place with history.replaceState({ ..., zl: false }) instead of a traversal, so the host never sees a popstate. Steps that stay on screen traverse exactly as before. The retired entry stays under the destination (a same-URL destination like the claim page replaces it), which is the same one-hop stale-sentinel tolerance the code already accepts.
    • apps/console — the /claim/ route sets shouldReload: false: the session is read once per document, and no navigation the router observes while the widget is up can hand the page to CompleteClaim. The intended way back is a full-document navigation, so the initial read is the only one that matters; router.invalidate() still reloads.

Validation

  • moon run components:test — zitadel-login.spec.ts 48/48; the new "retires the sentinel in place when the terminal step navigates away" case fails on the unpatched orchestrator (at the history.back call count) and passes with the fix.
  • moon run console:test — claim.spec.tsx 18/18; the new "does not spend the challenge when the widget's history sentinel moves the router" case renders on a real createBrowserHistory, replays the widget's pushState({ zl: true }) → history.back() with a session now available, and asserts the widget stays up, the loader ran once and claim/complete was never called. Without shouldReload: false it fails with the page on "Project claimed".
  • tsc --noEmit -p apps/console/tsconfig.spec.json clean; oxlint clean on the four touched files.
  • Not run: the cli-journey-e2e claim journey itself (needs the release snapshot); the full-pr job on this PR covers it.

Release notes / changeset

  • Changeset: .changeset/login-terminal-sentinel.md — @zitadel/components patch: <zitadel-login> no longer fires a host popstate when retiring its sentinel on a navigating terminal step. The console change ships with the server binary and needs no separate entry.

Notes

  • Either guard alone prevents this failure; both are here so the claim page doesn't depend on widget behaviour and the widget doesn't leak navigations into any host router, not just ours.
  • feat(console): branding settings beside a live login preview #1247 (and any other PR that reaches the fresh-app journey) can be re-run once this merges — the journey builds the release snapshot from the merge commit.
  • Follow-up worth a small PR: skipPasskeyUpsellIfVisible in claim.spec.ts still races on a "Skip for now" button that fix(login): land claims on sign-up, show the claim window, drop default-flow passkeys #1169 removed from the default flow, so a claim failure surfaces as a bare 30 s timeout; asserting on the outcome heading and failing fast on "Already claimed" would have named this bug directly.
  • Behavioural nit for reviewers: for a different-URL post-sign-in-url (e.g. /login → /), the in-place retirement leaves one extra history entry for the login URL under the destination; a back press lands on the login page signed in, which redirects. The previous history.back() popped it first. Judged acceptable given the alternative; happy to discuss.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 22, 2026 13:04
@vercel

vercel Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nextgen Ready Ready Preview Sep 22, 2026 1:45pm UTC
2 Skipped Deployments
Project Deployment Actions Updated
nextgen-docs Skipped Skipped Sep 22, 2026 1:45pm UTC
nextgen-mock-zitadel Skipped Skipped Sep 22, 2026 1:45pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: f83f8dd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@zitadel/components Patch
@zitadel/server Patch
@zitadel/sdk-angular Patch
@zitadel/sdk-next Patch
@zitadel/sdk-nuxt Patch
@zitadel/sdk-qwik Patch
@zitadel/sdk-react Patch
@zitadel/sdk-solid Patch
@zitadel/sdk-svelte Patch
@zitadel/sdk-vue Patch
@zitadel/cli Patch
@zitadel/testing Patch
@zitadel/server-linux-x64 Patch
@zitadel/server-linux-arm64 Patch
@zitadel/server-darwin-x64 Patch
@zitadel/server-darwin-arm64 Patch
@zitadel/server-win32-x64 Patch
@zitadel/api Patch
@zitadel/config Patch
@zitadel/sdk-core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The changeset omits the required @zitadel/server entry, and ADR 022 still documents the old sentinel-retirement behavior.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Fixes claim challenge double-spending caused by widget history traversal and console loader revalidation.

Changes:

  • Retires terminal sentinels with replaceState.
  • Prevents claim loader reloads during widget history moves.
  • Adds regression tests and a components changeset.
File Description
packages/​components/​src/​orchestrator/​zitadel-login.ts Updates terminal sentinel retirement.
packages/​components/​src/​orchestrator/​zitadel-login.spec.ts Tests navigation without host popstate.
apps/​console/​src/​routes/​claim/​index.tsx Disables loader reloads during same-document navigation.
apps/​console/​src/​routes/​claim/​claim.spec.tsx Tests claim protection during history moves.
.changeset/​login-terminal-sentinel.md Documents the components fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .changeset/login-terminal-sentinel.md
Comment thread packages/components/src/orchestrator/zitadel-login.ts
…y moves

The claim page completed its single-use challenge twice: once in the
document hosting the sign-in widget, once in the document the widget
navigated to, so the second answer was 409 and the page told the developer
their project was already claimed (cli-journey-e2e claim.spec).

The trace shows why. On the terminal step the widget retires its
back-gesture sentinel (ADR 022) with `history.back()`. That popstate is a
navigation to the console router, which reloads the claim route's loader;
by then the handoff exchange has set the session cookie, so the loader
finds a session, the page swaps the widget for CompleteClaim and spends the
challenge — two milliseconds after `location.assign` requested the next
document. The browser aborts the request on unload, the server has already
processed it, and the next document spends again.

Two independent guards:

- `<zitadel-login>` retires the sentinel with `history.replaceState` when
  the terminal step is about to navigate, so the host sees no popstate. A
  step that stays on screen traverses as before.
- The claim route sets `shouldReload: false`: the session is read once per
  document, and no navigation the router sees while the widget is up can
  hand the page to CompleteClaim. The intended way back is a full-document
  navigation.

Each guard has a unit test that fails without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BapEwS3cXLhZStpkF8xe28
ADR 022 said an armed sentinel without a back action is always retired
with history.back(); the implementation now retires it in place with
replaceState on a terminal step that navigates away. Record the exception,
why it exists, the resulting stack shape, and the host-side rule that
keeps the claim page reading the session once per document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BapEwS3cXLhZStpkF8xe28
@peintnermax peintnermax changed the title fix(claim): spend the claim challenge once across the widget's history moves fix: spend the claim challenge once across the widget's history moves Sep 22, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BapEwS3cXLhZStpkF8xe28
@vercel
vercel Bot temporarily deployed to Preview – nextgen-mock-zitadel September 22, 2026 13:45 Inactive
@vercel
vercel Bot temporarily deployed to Preview – nextgen-docs September 22, 2026 13:45 Inactive
@peintnermax peintnermax moved this from Inbox to In review in Engineering Kanban Sep 22, 2026
@peintnermax peintnermax self-assigned this Sep 22, 2026
@peintnermax
peintnermax merged commit 58c25fd into main Sep 22, 2026
14 checks passed
@peintnermax
peintnermax deleted the fix/claim-page-double-spend branch September 22, 2026 14:17
@github-project-automation github-project-automation Bot moved this from In review to Done in Engineering Kanban Sep 22, 2026

This branch was successfully deployed

1 active and 2 inactive deployments
Preview – nextgen — f83f8ddc Deployed Sep 22, 2026 by vercel[bot]
Preview – nextgen-mock-zitadel — f83f8ddc Deployed Sep 22, 2026 by vercel[bot]
Preview – nextgen-docs — f83f8ddc Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants