diff --git a/app/api/auth/zitadel-signout/route.ts b/app/api/auth/zitadel-signout/route.ts
new file mode 100644
index 0000000..a964c08
--- /dev/null
+++ b/app/api/auth/zitadel-signout/route.ts
@@ -0,0 +1,167 @@
+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).
+ *
+ * Exposed only on POST: a sign-out endpoint mutates state, so it must
+ * not be triggerable via `` or other cross-origin GET vectors.
+ * Auth.js v5's built-in /api/auth/signout enforces POST for the same
+ * reason. The success path returns 303 See Other so the browser follows
+ * the upstream Zitadel URL with GET (the verb /oidc/v1/end_session
+ * expects), without replaying the POST.
+ *
+ * See cdcf-infra/auth/handoffs/cdcf-website.md for the registered
+ * post-logout URIs per Zitadel client.
+ */
+export async function POST(req: NextRequest): Promise {
+ // Mirror lib/auth.ts's runtime AUTH_URL determination: AUTH_URL takes
+ // precedence, else NEXT_PUBLIC_SITE_URL (inlined at build time into
+ // server code by Next's DefinePlugin). Doing this independently rather
+ // than reading process.env.AUTH_URL alone covers a cold-start where
+ // this route handler runs BEFORE lib/auth.ts's top-level `process.env.
+ // AUTH_URL = ...` fallback has executed — otherwise isSecure would be
+ // false on HTTPS and we'd look up the cookie by the wrong (unprefixed)
+ // name, failing to read token.idToken and skipping id_token_hint.
+ const siteUrl =
+ 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
+ // affect it — but ordering this way keeps the intent clear.
+ const token = await getToken({
+ req,
+ secret: process.env.AUTH_SECRET,
+ secureCookie: isSecure,
+ })
+
+ // 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 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
+ // 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. Use 303 here too so the browser follows with GET.
+ if (!issuer) {
+ return NextResponse.redirect(new URL('/', req.url), 303)
+ }
+
+ 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). Use the same fallback chain as siteUrl above
+ // so misconfigured deploys still produce a registered URI rather than
+ // silently dropping the param and bouncing to Zitadel's default.
+ if (siteUrl !== '') {
+ endSession.searchParams.set('post_logout_redirect_uri', siteUrl)
+ }
+
+ // 303 — the incoming method was POST; the browser must follow with GET
+ // because /oidc/v1/end_session is GET-only and we don't want to replay
+ // the cookie-clearing POST against Zitadel.
+ return NextResponse.redirect(endSession.toString(), 303)
+}
diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx
index 3d405f6..0d7d69a 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,14 +158,29 @@ export default function AuthButton() {
{t('editMyBio')}
)}
-
+
+
)}
diff --git a/lib/auth.ts b/lib/auth.ts
index e618ddf..387b6d4 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -35,6 +35,13 @@ declare module 'next-auth/jwt' {
interface JWT {
accessToken?: string
refreshToken?: string
+ // The OIDC id_token from initial sign-in. Required as id_token_hint
+ // on Zitadel's RP-initiated logout endpoint so the upstream session
+ // is terminated alongside our local cookie (otherwise the next
+ // sign-in silently reuses the Zitadel SSO session and the user
+ // can't switch accounts). Not refreshed — Zitadel issues a fresh
+ // id_token on each sign-in, the original is enough for end_session.
+ idToken?: string
expiresAt?: number
roles?: string[]
locale?: string
@@ -44,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
// { "": { "": "" }, ... }
@@ -114,8 +141,19 @@ 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.
+ prompt: 'select_account',
},
},
}),
@@ -127,6 +165,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
+ token.idToken = account.id_token
token.expiresAt = account.expires_at
}
if (profile) {