Skip to content

fix(auth): recover from provider-side dead sessions after OIDC logout - #109

Merged
yahyafakhroji merged 1 commit into
mainfrom
fix/post-logout-passkey-recovery
Jul 31, 2026
Merged

fix(auth): recover from provider-side dead sessions after OIDC logout#109
yahyafakhroji merged 1 commit into
mainfrom
fix/post-logout-passkey-recovery

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes two staging bugs that share one cause: the sessions cookie can outlive the Zitadel session it names. Liveness is judged from cookie-local expirationTs, 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-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, and the surviving entry 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. "The passkey verification failed. Please try again."

requestWebAuthnChallenge swallowed the resulting NOT_FOUND from updateSession into a null challenge. The verify screen still rendered 200, and WebAuthnButton's !publicKey guard 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: armUserBoundChallenge calls requestWebAuthnChallenge itself, 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

  • New service spec for the stale-session self-heal (RED → GREEN)
  • New route-boundary spec asserting the re-minted session rides back as a Set-Cookie — without it the assertion would verify against the dead entry
  • New regression spec for cookie clearing on /logout/success
  • Full Cypress component suite: 748/748
  • tsc --noEmit clean for both app and cypress
  • Verify on staging: log in with passkey → log out → sign in again shows the passkey dialog
  • Verify on staging: log out → /id/accounts → pick the account → Passkey → dialog opens instead of an error

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
yahyafakhroji force-pushed the fix/post-logout-passkey-recovery branch from 4b13bae to f4e0e90 Compare July 31, 2026 06:42
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Summary

Job Status
E2E (fast, fake provider) 🚫 cancelled
Unit Tests (component) (0) 🚫 cancelled
Unit Tests (component) (1) 🚫 cancelled
Unit Tests (component) (2) 🚫 cancelled

View workflow run

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Summary

Job Status
E2E (fast, fake provider) ✅ success
Unit Tests (component) (0) ✅ success
Unit Tests (component) (1) ✅ success
Unit Tests (component) (2) ✅ success

View workflow run

@yahyafakhroji
yahyafakhroji enabled auto-merge July 31, 2026 07:07
@yahyafakhroji
yahyafakhroji merged commit cd44952 into main Jul 31, 2026
18 checks passed
@yahyafakhroji
yahyafakhroji deleted the fix/post-logout-passkey-recovery branch July 31, 2026 07:14
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.

2 participants