Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions app/api/auth/zitadel-signout/route.ts
Original file line number Diff line number Diff line change
@@ -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 `<img>` 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<NextResponse> {
// 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://')
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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)
}
31 changes: 23 additions & 8 deletions components/AuthButton.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -158,14 +158,29 @@ export default function AuthButton() {
{t('editMyBio')}
</Link>
)}
<button
type="button"
role="menuitem"
onClick={() => signOut()}
className="block w-full px-4 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-cdcf-navy"
{/*
Form POST (not next-auth/react's signOut() or next/link or a
window.location GET) so the request reaches our RP-initiated
logout route as POST — which clears the Auth.js cookies AND
303s through Zitadel's /oidc/v1/end_session to terminate the
upstream SSO session. POST (rather than GET) makes the route
CSRF-safe: a sign-out endpoint mutates state, and a GET form
would be triggerable via `<img src>` on any third-party page.
The browser handles the 303 and follows with GET to Zitadel.
*/}
<form
action="/api/auth/zitadel-signout"
method="POST"
className="block"
>
{t('signOut')}
</button>
<button
type="submit"
role="menuitem"
className="block w-full px-4 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-cdcf-navy"
>
{t('signOut')}
</button>
</form>
</div>
)}
</div>
Expand Down
43 changes: 41 additions & 2 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// { "<roleKey>": { "<orgId>": "<orgName>" }, ... }
Expand Down Expand Up @@ -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',
},
},
}),
Expand All @@ -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) {
Expand Down