From 0d3a4c2fc9865fb8ef1b2a9cf9cbde14512afbfd Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Fri, 5 Jun 2026 20:41:12 -0400 Subject: [PATCH 1/5] fix(auth): RP-initiated logout via Zitadel /oidc/v1/end_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/auth/zitadel-signout/route.ts | 83 +++++++++++++++++++++++++++ components/AuthButton.tsx | 15 ++++- lib/auth.ts | 8 +++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 app/api/auth/zitadel-signout/route.ts diff --git a/app/api/auth/zitadel-signout/route.ts b/app/api/auth/zitadel-signout/route.ts new file mode 100644 index 0000000..eb9414a --- /dev/null +++ b/app/api/auth/zitadel-signout/route.ts @@ -0,0 +1,83 @@ +import { cookies } from 'next/headers' +import { type NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' + +/** + * RP-initiated logout for the Zitadel session. + * + * 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 route does both: clear the + * Auth.js cookies AND redirect the browser to Zitadel's RP-initiated + * logout endpoint, which kills the upstream session and bounces back to + * post_logout_redirect_uri (registered per env in setup-zitadel.sh). + * + * See cdcf-infra/auth/handoffs/cdcf-website.md for the registered + * post-logout URIs per Zitadel client. + */ +export async function GET(req: NextRequest): Promise { + // Whether Auth.js v5 used Secure-prefixed cookie names. The cookie + // naming convention is tied to whether the deployment is HTTPS — same + // signal Auth.js uses internally — so derive from AUTH_URL rather than + // a separate flag. + const isSecure = (process.env.AUTH_URL ?? '').startsWith('https://') + + // Pull the id_token from the JWT BEFORE we delete the cookie. getToken + // reads from the request cookies (an immutable snapshot at request + // start), so deleting from the response cookie store first wouldn't + // affect it — but ordering this way keeps the intent clear. + const token = await getToken({ + req, + secret: process.env.AUTH_SECRET, + secureCookie: isSecure, + }) + + // Clear the Auth.js v5 session cookies. We touch both the prefixed and + // unprefixed names so a misconfigured env (e.g. HTTPS deploy that + // forgot to set AUTH_URL) still gets cleaned up, never stranding the + // user logged-in-locally-but-logged-out-of-Zitadel. + const store = await cookies() + for (const name of [ + 'authjs.session-token', + '__Secure-authjs.session-token', + 'authjs.csrf-token', + '__Host-authjs.csrf-token', + 'authjs.callback-url', + '__Secure-authjs.callback-url', + ]) { + store.delete(name) + } + + const issuer = process.env.AUTH_ZITADEL_ISSUER + // Without an issuer there's nothing to redirect to for the upstream + // session termination — fall back to the local sign-in page so the + // user at least lands somewhere sensible. The local session is gone + // either way. + if (!issuer) { + return NextResponse.redirect(new URL('/', req.url)) + } + + const endSession = new URL('/oidc/v1/end_session', issuer) + + // id_token_hint is REQUIRED by Zitadel to skip the logout-confirmation + // prompt and to reliably terminate the right session. If we don't have + // one (cookie expired before the user clicked sign-out, etc.) the + // request still works but Zitadel may render a confirmation page. + const idToken = token?.idToken + if (typeof idToken === 'string' && idToken !== '') { + endSession.searchParams.set('id_token_hint', idToken) + } + const clientId = process.env.AUTH_ZITADEL_ID + if (typeof clientId === 'string' && clientId !== '') { + endSession.searchParams.set('client_id', clientId) + } + // post_logout_redirect_uri must be one of the URIs registered on the + // Zitadel OIDC app for this client_id (setup-zitadel.sh registers the + // public origin per env). AUTH_URL matches that by construction. + const postLogout = process.env.AUTH_URL + if (typeof postLogout === 'string' && postLogout !== '') { + endSession.searchParams.set('post_logout_redirect_uri', postLogout) + } + + return NextResponse.redirect(endSession.toString()) +} diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx index 3d405f6..f1b0d8d 100644 --- a/components/AuthButton.tsx +++ b/components/AuthButton.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { useSession, signIn, signOut } from 'next-auth/react' +import { useSession, signIn } from 'next-auth/react' import { useTranslations } from 'next-intl' import { ChevronDownIcon, UserIcon } from '@heroicons/react/24/outline' import clsx from 'clsx' @@ -158,10 +158,21 @@ export default function AuthButton() { {t('editMyBio')} )} + {/* + Full browser navigation (window.location, not next-auth/react's + signOut() or next/link) so the request goes through our + RP-initiated logout route — which clears the Auth.js cookies + AND 302s through Zitadel's /oidc/v1/end_session to terminate + the upstream SSO session. signOut() alone leaves Zitadel's + session intact; next/link would skip the API route entirely + via client-side routing. + */} + + )} diff --git a/lib/auth.ts b/lib/auth.ts index 0adf641..373ecea 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -123,6 +123,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // offline_access → refresh token; the project:roles scope // makes Zitadel include the role claim in the id_token. scope: `openid profile email offline_access ${ZITADEL_ROLES_CLAIM}`, + // Force the Zitadel account chooser on every sign-in so an + // active SSO session for a sibling property (LitCal/OntoKit/ + // BibleGet) or the umbrella IAM admin doesn't silently pass + // through to cdcf-website. The user must actively pick which + // account they want to sign in as. Pairs with the RP-initiated + // logout route — together they keep account switching usable. + // + // hasProjectCheck on the CDCF Website Zitadel project (TODO, + // separate cdcf-infra PR) will be the structural enforcement; + // this prompt is the application-layer UX guard that ships + // ahead of that infra change. + prompt: 'select_account', }, }, }), From a62386167828d57ae090a0939987ba00c0d01557 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Fri, 5 Jun 2026 21:21:06 -0400 Subject: [PATCH 3/5] feat(auth): scope OIDC authorize to CDCF Org via AUTH_ZITADEL_ORG_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/auth.ts | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/auth.ts b/lib/auth.ts index 373ecea..387b6d4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -51,6 +51,26 @@ declare module 'next-auth/jwt' { const ZITADEL_ROLES_CLAIM = 'urn:zitadel:iam:org:project:roles' +// Zitadel-supported scope that restricts authentication AND new-user +// registration to a single Organization within the instance. Without it, +// the umbrella Zitadel happily authorizes any instance-wide user against +// cdcf-website's client_id — and registration drops new users into the +// instance's default Org (typically the bootstrap ZITADEL Org), not the +// CDCF Org. Setting AUTH_ZITADEL_ORG_ID to the CDCF Org ID (see the +// cdcf-infra handoff doc) makes Zitadel: route registrations into the +// CDCF Org, reject login attempts from sibling-property Org users and +// from the umbrella IAM admin. Reference: Zitadel docs, "Restrict Login +// to a single Organization". +// +// If unset, behavior falls back to instance-wide auth (the original +// PR #172 behaviour) so a misconfigured deploy still works for CDCF Org +// users — it just doesn't enforce the cross-Org isolation. +function buildOrgScope(): string { + const orgId = process.env.AUTH_ZITADEL_ORG_ID + if (typeof orgId !== 'string' || orgId === '') return '' + return ` urn:zitadel:iam:org:id:${orgId}` +} + function extractRoles(claim: unknown): string[] { // Zitadel emits the project-roles claim as // { "": { "": "" }, ... } @@ -121,19 +141,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ authorization: { params: { // offline_access → refresh token; the project:roles scope - // makes Zitadel include the role claim in the id_token. - scope: `openid profile email offline_access ${ZITADEL_ROLES_CLAIM}`, + // makes Zitadel include the role claim in the id_token; the + // org:id scope (when AUTH_ZITADEL_ORG_ID is set) restricts + // auth + registration to the CDCF Org — see buildOrgScope. + scope: + `openid profile email offline_access ${ZITADEL_ROLES_CLAIM}` + + buildOrgScope(), // Force the Zitadel account chooser on every sign-in so an // active SSO session for a sibling property (LitCal/OntoKit/ // BibleGet) or the umbrella IAM admin doesn't silently pass // through to cdcf-website. The user must actively pick which // account they want to sign in as. Pairs with the RP-initiated // logout route — together they keep account switching usable. - // - // hasProjectCheck on the CDCF Website Zitadel project (TODO, - // separate cdcf-infra PR) will be the structural enforcement; - // this prompt is the application-layer UX guard that ships - // ahead of that infra change. prompt: 'select_account', }, }, From 0d74271906fff50476de03e35728b79e889b5fa4 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Fri, 5 Jun 2026 22:49:06 -0400 Subject: [PATCH 4/5] fix(auth): clear chunked Auth.js session-token cookies on signout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base-name list miss the .0/.1/.2 chunked variants Auth.js v5 splits the JWE session token across when it exceeds the ~4KB per-cookie limit. Observed on staging: id_token + project:roles + refresh_token bloated the JWE to ~5KB, so it split into `__Secure-authjs.session-token.0` (4KB) + `.1` (700B). The route's hard-coded delete of the bare `__Secure-authjs.session-token` no-op'd and both chunks survived — user effectively still signed in after clicking sign-out. Iterate req.cookies.getAll() and delete any whose name starts with an Auth.js prefix instead. Handles arbitrary chunk counts and any future cookie naming Auth.js might introduce. Also switch from cookies().delete(name) to cookies().set(name, '', { maxAge: 0, ... }) with prefix-appropriate flags. Browsers ignore deletion Set-Cookie responses for `__Host-` / `__Secure-` prefixed cookies when the response doesn't carry matching Secure/Path attributes, and Next's default delete() doesn't set Secure — so even when we had the right name, the browser quietly kept the cookie. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/auth/zitadel-signout/route.ts | 48 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/app/api/auth/zitadel-signout/route.ts b/app/api/auth/zitadel-signout/route.ts index 41562d9..9aeaafa 100644 --- a/app/api/auth/zitadel-signout/route.ts +++ b/app/api/auth/zitadel-signout/route.ts @@ -45,20 +45,42 @@ export async function POST(req: NextRequest): Promise { secureCookie: isSecure, }) - // Clear the Auth.js v5 session cookies. We touch both the prefixed and - // unprefixed names so a misconfigured env (e.g. HTTPS deploy that - // forgot to set AUTH_URL) still gets cleaned up, never stranding the - // user logged-in-locally-but-logged-out-of-Zitadel. + // Clear EVERY Auth.js v5 cookie the browser sent. We enumerate + // req.cookies rather than hard-coding base names because Auth.js + // CHUNKS large session JWTs across `.0` / `.1` / `.2` suffixed + // cookies (the JWE grows past Zitadel's id_token + the project:roles + // claim + refresh_token, easily exceeding the ~4KB per-cookie limit) + // — a hard-coded `__Secure-authjs.session-token` delete would + // silently miss the chunks and leave the user effectively still + // signed in. Observed on staging where the JWE was split into + // `.0` (~4KB) + `.1` (~700B) cookies. + // + // We also explicitly set Max-Age=0 with the prefix-appropriate flags + // (Secure for `__Secure-` / `__Host-`, no Domain for `__Host-`) + // because browsers IGNORE deletion responses that don't match the + // prefix semantics — Next's cookies().delete() default flags are + // wrong for prefixed cookies and the cookies survive the response. + const AUTHJS_PREFIXES = ['authjs.', '__Secure-authjs.', '__Host-authjs.'] const store = await cookies() - for (const name of [ - 'authjs.session-token', - '__Secure-authjs.session-token', - 'authjs.csrf-token', - '__Host-authjs.csrf-token', - 'authjs.callback-url', - '__Secure-authjs.callback-url', - ]) { - store.delete(name) + for (const c of req.cookies.getAll()) { + if (!AUTHJS_PREFIXES.some((p) => c.name.startsWith(p))) continue + const isHostPrefix = c.name.startsWith('__Host-') + const isPrefixSecure = isHostPrefix || c.name.startsWith('__Secure-') + store.set(c.name, '', { + maxAge: 0, + path: '/', + httpOnly: true, + // __Host- and __Secure- prefixes REQUIRE Secure on both set and + // delete; for unprefixed names fall back to whatever isSecure + // says (matches lib/auth.ts's HTTPS detection). + secure: isPrefixSecure || isSecure, + sameSite: 'lax', + // __Host- cookies must NOT have a Domain attribute — leaving it + // unset preserves that constraint. For non-Host names, also + // leaving Domain unset is fine: cookies set without Domain are + // scoped to the exact host the browser sees, which matches how + // Auth.js writes them in the first place. + }) } const issuer = process.env.AUTH_ZITADEL_ISSUER From 7b827537e3131b08baff0b52ab3dec8f8be5ae31 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Fri, 5 Jun 2026 23:20:40 -0400 Subject: [PATCH 5/5] fix(auth): reject cross-origin POST to /api/auth/zitadel-signout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit finding on PR #177: the signout POST handler mutates state unconditionally — a malicious cross-site form POST would bounce the user through Zitadel's /oidc/v1/end_session confirmation page even when SameSite=Lax keeps the actual session cookies from reaching us. Add an Origin/Referer same-origin check that runs BEFORE any cookie read or write. Modern browsers send Origin on every POST (cross- and same-origin); Referer is the fallback for the rare client that strips it. If neither matches the configured AUTH_URL / NEXT_PUBLIC_SITE_URL origin, return 403 without touching cookies and without redirecting to Zitadel. We also refuse to mutate state when our own expected origin can't be resolved from siteUrl — a visible 403 on a misconfigured deploy is strictly better than silently accepting cross-site sign-outs because the CSRF check no-op'd. Auth.js v5's built-in /api/auth/signout uses a CSRF token cookie for the same purpose. Same-origin enforcement gives equivalent protection with strictly less state to wire through the client side (no hidden form field, no token round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/auth/zitadel-signout/route.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/api/auth/zitadel-signout/route.ts b/app/api/auth/zitadel-signout/route.ts index 9aeaafa..a964c08 100644 --- a/app/api/auth/zitadel-signout/route.ts +++ b/app/api/auth/zitadel-signout/route.ts @@ -35,6 +35,51 @@ export async function POST(req: NextRequest): Promise { process.env.AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? '' const isSecure = siteUrl.startsWith('https://') + // CSRF defence — state-changing POST must originate from our own + // origin. SameSite=Lax on the Auth.js session cookies already keeps + // them from being sent on a cross-site POST (so the cookie-deletion + // path below would no-op), but a malicious cross-site POST would + // still bounce the user through Zitadel's end_session confirmation + // page on the response redirect — annoying, plus we shouldn't rely + // on browser cookie behaviour to make a state-mutating endpoint + // safe. Modern browsers send Origin on every POST (cross-origin and + // same-origin); Referer is the fallback for the rare client that + // strips it. If both are missing or neither matches our expected + // origin, refuse to mutate state. Auth.js v5's built-in + // /api/auth/signout uses a CSRF token cookie for the same purpose; + // we get equivalent protection from same-origin enforcement with + // strictly less state to wire through the client side. + const expectedOrigin = (() => { + if (siteUrl === '') return '' + try { + const u = new URL(siteUrl) + return `${u.protocol}//${u.host}` + } catch { + return '' + } + })() + if (expectedOrigin === '') { + // Can't determine our own origin — refuse to mutate state. A + // visible 403 here surfaces the misconfiguration; the alternative + // (skipping the CSRF check on a broken deploy) silently accepts + // cross-site sign-outs. + return new NextResponse('Forbidden', { status: 403 }) + } + const requestOriginMatches = (header: string | null): boolean => { + if (header === null || header === '') return false + try { + const u = new URL(header) + return `${u.protocol}//${u.host}` === expectedOrigin + } catch { + return false + } + } + const origin = req.headers.get('origin') + const referer = req.headers.get('referer') + if (!requestOriginMatches(origin) && !requestOriginMatches(referer)) { + return new NextResponse('Forbidden', { status: 403 }) + } + // Pull the id_token from the JWT BEFORE we delete the cookie. getToken // reads from the request cookies (an immutable snapshot at request // start), so deleting from the response cookie store first wouldn't