fix(auth): recover from provider-side dead sessions after OIDC logout - #109
Merged
Conversation
Two defects sharing one cause: a `sessions` cookie that outlives the Zitadel session it names. Liveness is judged from cookie-local `expirationTs`, which cannot see a provider-side termination, so an orphaned entry reads as live for its full 24h. 1. logout — Zitadel is registered with `post-logout-redirect-uris` pointing straight at /id/logout/success, so the end_session hop bypasses /id/logout, the only route that ran completeOidcLogout. Neither `sessions` nor `passkey-hint` was ever cleared. The surviving entry then suppressed the /login passkey fast path via hasLiveSession, leaving returning users on a bare email field. /logout/success is the RP's registered landing page, so it now clears both cookies itself. 2. webauthn — requestWebAuthnChallenge swallowed the resulting NOT_FOUND from updateSession into a null challenge. The verify screen still rendered 200, and WebAuthnButton's !publicKey guard reported "The passkey verification failed. Please try again." without any ceremony having run: nothing was verified, and no retry could ever succeed because each one re-read the same dead entry. A stale session now re-mints a user-bound session and arms the challenge on it, so the click that follows opens a real passkey dialog. Recovery is opt-in rather than unconditional: armUserBoundChallenge calls requestWebAuthnChallenge itself, so recovering in the catch would let a stale error recurse back into it. Only the verify loader passes the parameter, which makes the cycle structurally impossible. Reaching the self-heal also requires an entry in the HMAC-signed cookie naming that loginName, so the URL's loginName cannot be forged into it, and the minted session carries no verified factors until the assertion succeeds. Non-stale challenge failures still degrade as before — a transient backend fault is genuinely retryable. Both misconfigurations are present in production; these app-side fixes cover staging and production with no infra change. Coverage: service-level spec for the stale-session self-heal, plus a route-boundary spec asserting the re-minted session rides back as a Set-Cookie (without it the assertion would verify against the dead entry — the same bug moved one step later). Also ignores .claude/skills/ and .claude/worktrees/ (local-only state).
yahyafakhroji
force-pushed
the
fix/post-logout-passkey-recovery
branch
from
July 31, 2026 06:42
4b13bae to
f4e0e90
Compare
Contributor
🧪 Test Summary
|
Contributor
🧪 Test Summary
|
yahyafakhroji
enabled auto-merge
July 31, 2026 07:07
gaghan430
approved these changes
Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes two staging bugs that share one cause: the
sessionscookie can outlive the Zitadel session it names. Liveness is judged from cookie-localexpirationTs, which can't see a provider-side termination, so an orphaned entry looks live for its full 24h.1. Passkey didn't trigger after logout
Zitadel is registered with
post-logout-redirect-urispointing straight at/id/logout/success, so theend_sessionhop bypasses/id/logout— the only route that rancompleteOidcLogout. Neithersessionsnorpasskey-hintwas ever cleared, and the surviving entry suppressed the/loginpasskey fast path viahasLiveSession, leaving returning users on a bare email field./logout/successis the RP's registered landing page, so it now clears both cookies itself.2. "The passkey verification failed. Please try again."
requestWebAuthnChallengeswallowed the resultingNOT_FOUNDfromupdateSessioninto a null challenge. The verify screen still rendered 200, andWebAuthnButton's!publicKeyguard reported a verification failure without any ceremony having run — nothing was verified, and no retry could succeed because each one re-read the same dead entry.A stale session now re-mints a user-bound session and arms the challenge on it, so the click that follows opens a real passkey dialog. Recovery is opt-in:
armUserBoundChallengecallsrequestWebAuthnChallengeitself, so only the verify loader passes the parameter, which makes recursion structurally impossible. Non-stale failures still degrade as before, since a transient backend fault is genuinely retryable.Test plan
Set-Cookie— without it the assertion would verify against the dead entry/logout/successtsc --noEmitclean for both app and cypress/id/accounts→ pick the account → Passkey → dialog opens instead of an error