Skip to content

fix(auth): RP-initiated logout terminates Zitadel SSO session#177

Open
JohnRDOrazio wants to merge 3 commits into
mainfrom
fix/zitadel-rp-initiated-logout
Open

fix(auth): RP-initiated logout terminates Zitadel SSO session#177
JohnRDOrazio wants to merge 3 commits into
mainfrom
fix/zitadel-rp-initiated-logout

Conversation

@JohnRDOrazio
Copy link
Copy Markdown
Member

@JohnRDOrazio JohnRDOrazio commented Jun 6, 2026

Summary

  • Sign-out button now routes through a new `GET /api/auth/zitadel-signout` that clears the Auth.js v5 session cookie AND 302s through Zitadel's `/oidc/v1/end_session` to terminate the upstream SSO session.
  • `id_token` is captured in the JWT initial-sign-in branch (required as `id_token_hint` so Zitadel skips the logout-confirmation prompt).
  • `AuthButton` uses a full `window.location` navigation rather than `signOut()` or `next/link` — the route handler needs a real HTTP GET to set cookies and follow the 302 chain.

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

  • Local: `npm run dev` → sign in as Account A → sign out via dropdown → expect a 302 to `auth.catholicdigitalcommons.org/oidc/v1/end_session` → landing back on `localhost:3000`. Click "Sign in" → Zitadel shows the login page (no auto-resume of Account A). Sign in as Account B → header shows B's email.
  • Staging: same flow against `staging.catholicdigitalcommons.org` once deployed.
  • Production: same after the staging round-trip clears.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Improved sign-out security with enhanced session management ensuring complete termination of both local sessions and upstream authentication services
    • Better authentication token handling for comprehensive session cleanup and logout workflow improvements
    • Reduced security vulnerabilities through more robust session termination across integrated services

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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 6, 2026

Need an answer fast? Review this PR in Change Stack to ask focused questions about the PR or a changed range.

Review Change Stack

Warning

Review limit reached

@JohnRDOrazio, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 522ebfd4-3eea-4380-b3f6-00d508e1604c

📥 Commits

Reviewing files that changed from the base of the PR and between 0d3a4c2 and a623861.

📒 Files selected for processing (3)
  • app/api/auth/zitadel-signout/route.ts
  • components/AuthButton.tsx
  • lib/auth.ts
📝 Walkthrough

Walkthrough

This 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.

Changes

Zitadel Logout Flow

Layer / File(s) Summary
JWT idToken claim storage
lib/auth.ts
The NextAuth JWT type is augmented with an optional idToken field. During the initial sign-in callback (account present), the value from account.id_token is copied to token.idToken for later use.
Zitadel logout endpoint
app/api/auth/zitadel-signout/route.ts
A new GET route implements RP-initiated logout. It retrieves the stored id_token from the JWT, deletes Auth.js cookies (both prefixed and unprefixed variants), reads the AUTH_ZITADEL_ISSUER environment variable, and redirects to Zitadel's /oidc/v1/end_session endpoint with id_token_hint, client_id, and post_logout_redirect_uri parameters. If the issuer is not configured, it falls back to redirecting to the local / route.
AuthButton logout integration
components/AuthButton.tsx
The sign-out handler is updated to navigate the browser to /api/auth/zitadel-signout via window.location.href instead of calling signOut() directly. A block comment explains that this full-page navigation enables the logout endpoint to clear cookies and perform RP-initiated logout to the upstream SSO provider.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🐰 A logout with purpose, clean and bright,
Zitadel's session ends tonight,
Cookies cleared, with id_token sent,
SSO session—fully spent! ✨🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: implementing RP-initiated logout that terminates the Zitadel SSO session. It is specific, concise, and directly reflects the core objective of the changeset across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/zitadel-rp-initiated-logout

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Jun 6, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity · 0 duplication

Metric Results
Complexity 12
Duplication 0

View in Codacy

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-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8960ed8 and 0d3a4c2.

📒 Files selected for processing (3)
  • app/api/auth/zitadel-signout/route.ts
  • components/AuthButton.tsx
  • lib/auth.ts

Comment thread app/api/auth/zitadel-signout/route.ts Outdated
Comment thread app/api/auth/zitadel-signout/route.ts Outdated
JohnRDOrazio and others added 2 commits June 5, 2026 21:02
…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>
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