fix(auth): RP-initiated logout terminates Zitadel SSO session#177
fix(auth): RP-initiated logout terminates Zitadel SSO session#177JohnRDOrazio wants to merge 3 commits into
Conversation
Auth.js's signOut() only clears the local session cookie; the upstream
Zitadel SSO session survives, so the next sign-in silently reuses it
and the user can't switch accounts. This routes the sign-out button
through a new GET /api/auth/zitadel-signout that:
1. Reads id_token from the JWT (now captured in the jwt callback's
initial-sign-in branch — required as id_token_hint for end_session).
2. Clears the Auth.js v5 session cookies (both prefixed and unprefixed
names, so a misconfigured HTTPS-without-AUTH_URL deploy still gets
cleaned up — defence against logged-in-locally-but-logged-out-of-
Zitadel half-state).
3. 302s to {AUTH_ZITADEL_ISSUER}/oidc/v1/end_session with id_token_hint
+ client_id + post_logout_redirect_uri. The redirect URI matches the
per-env URI registered by setup-zitadel.sh --provision-cdcf-website,
so Zitadel completes the round-trip back to the public origin.
AuthButton uses a full window.location navigation rather than next/link
because the route handler needs a real HTTP GET to set cookies and
follow the 302 chain; client-side routing would skip it entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Need an answer fast? Review this PR in Change Stack to ask focused questions about the PR or a changed range. Warning Review limit reached
More reviews will be available in 20 minutes and 30 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR implements RP-initiated logout for Zitadel OIDC sessions. The JWT now stores the id_token during sign-in; a new endpoint clears Auth.js cookies and redirects to Zitadel's end-session endpoint, and AuthButton is updated to navigate to this endpoint instead of using the client-side signOut function. ChangesZitadel Logout Flow
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~20 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 12 |
| Duplication | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/api/auth/zitadel-signout/route.ts`:
- Line 18: The logout route is currently implemented as an exported GET handler
(export async function GET) but performs state changes; change it to an exported
POST handler (export async function POST(req: NextRequest):
Promise<NextResponse>) so the endpoint is not CSRF/side-effectable, and ensure
any forms or clients call POST. When redirecting to the Zitadel logout hop
inside this handler (the existing redirect logic currently used in GET), return
a 303 See Other response so the browser follows the upstream Zitadel URL with
GET rather than replaying the mutation; keep your cookie-clearing and upstream
termination logic but move it into POST and update any references to the old GET
handler (also apply same change where the same pattern appears around the other
occurrence noted).
- Around line 23-33: The route uses AUTH_URL directly to set isSecure which can
mismatch how cookies are created; change the isSecure calculation to mirror
lib/auth.ts by using the same URL fallback (use process.env.AUTH_URL ??
process.env.NEXT_PUBLIC_SITE_URL) and derive secure by checking
startsWith('https://'), then pass that secure value into getToken({ req, secret:
process.env.AUTH_SECRET, secureCookie: isSecure }) so the JWT cookie lookup uses
the same site URL logic as cookie creation and avoids missing token?.idToken.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 70bc8847-4ca2-4173-a18d-e13ebca0b894
📒 Files selected for processing (3)
app/api/auth/zitadel-signout/route.tscomponents/AuthButton.tsxlib/auth.ts
…ecure fallback Three follow-up changes on the RP-initiated logout PR: 1. Sign-out route is now POST + returns 303 See Other (was GET + 302). GET on a state-changing endpoint is CSRF-able (an attacker's `<img src="/api/auth/zitadel-signout">` would log visitors out). Auth.js v5's built-in /api/auth/signout enforces POST for the same reason. AuthButton submits a hidden form to drive the POST; the browser handles the 303 and follows with GET to /oidc/v1/end_session (the verb Zitadel expects there). 2. isSecure now mirrors lib/auth.ts's AUTH_URL determination — falls back to NEXT_PUBLIC_SITE_URL when AUTH_URL is unset. Without this, a cold-start request to /api/auth/zitadel-signout that hits BEFORE lib/auth.ts has been imported (and thus before its top-level AUTH_URL = NEXT_PUBLIC_SITE_URL fallback has run) would treat the deploy as non-HTTPS, look up the cookie at the unprefixed name, miss token.idToken, and emit end_session without id_token_hint. post_logout_redirect_uri uses the same fallback so misconfigured deploys still send a registered URI. 3. Authorize params now include `prompt=select_account` so Zitadel shows the account chooser on every sign-in. Without this, an active SSO session for a sibling umbrella property (LitCal/OntoKit) or the umbrella IAM admin silently passes through to cdcf-website — leaking the wrong account in via "Sign in" pass-through. This is the application-layer UX guard; the structural fix is hasProjectCheck on the CDCF Website Zitadel project (separate cdcf-infra PR, will walk back Phase 5's implicit-Subscriber decision in the same swing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The umbrella Zitadel instance hosts multiple Orgs (CDCF, LitCal, OntoKit,
BibleGet, plus the bootstrap ZITADEL Org). By default Zitadel:
- Authorizes ANY instance-wide user against ANY OIDC client_id —
including cross-Org users (LitCal users, IAM admin, etc.).
- Drops new sign-ups into the instance default Org (the bootstrap
ZITADEL Org) rather than the per-property Org the client expects.
Both surfaced when testing: a ZITADEL Org user successfully completed
OIDC code exchange (would have provisioned a stray WP Subscriber if the
cookie-size 502 hadn't intervened), and a new sign-up landed in the
ZITADEL Org instead of CDCF.
Fix: pass the Zitadel-supported `urn:zitadel:iam:org:id:{orgId}` scope
on the authorize request when AUTH_ZITADEL_ORG_ID is set. Zitadel then
restricts auth + registration to that Org. No infra change needed.
Falls back to the original instance-wide behaviour when the env var is
unset, so a deploy missing the new var still works for CDCF Org users —
it just doesn't enforce cross-Org isolation.
Requires AUTH_ZITADEL_ORG_ID in Plesk's Node.js env per app (both
staging and prod point at the same CDCF Org ID; see the cdcf-infra
handoff doc for the value).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Why
Reported on staging after the prior `AUTH_URL`-in-Plesk fix landed: login worked, but signing out left the upstream Zitadel SSO cookie alive. Clicking "Sign in" again silently reused the previous session — no way to switch accounts.
The standard OIDC remedy is RP-initiated logout, which Auth.js's built-in `signOut()` does not implement.
Cookie cleanup is belt-and-suspenders
The route deletes both prefixed (`__Secure-`/`__Host-`) and unprefixed Auth.js cookie names so a misconfigured HTTPS-without-`AUTH_URL` deploy doesn't strand a half-cleared session. Auth.js's cookie naming depends on the deploy's HTTPS posture; this route doesn't trust that inference and just deletes both variants.
Post-logout redirect URI registration
The 302 to Zitadel uses `post_logout_redirect_uri = AUTH_URL`. The matching URIs are registered per-env via `cdcf-infra/auth/setup-zitadel.sh --provision-cdcf-website` (handoff doc lists them: prod origin on the prod client, `staging.*` + `localhost:3000` on the non-prod client). No infra change needed.
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit