fix(login): show account selection after RP-initiated logout (#12638)

**Supersedes #12471** by @conblem — same fix, rebased onto current
`main` with conflicts resolved and review feedback applied. Original
commits are preserved, all credit to @conblem.

Closes #12252

### Problem

After an RP-initiated logout (`end_session` with `id_token_hint`), Login
V2 redirected straight to `/loginname` instead of showing the account
picker like Login V1. The `sessions` cookie still referenced the
previous accounts, but their server-side sessions had been terminated,
so `listSessions` returned nothing and the flow treated the browser as
having no accounts at all.

### Solution

- `handleOIDCFlowInitiation` now checks the `sessions` cookie
(`hasCookieAccount`) when no live session is eligible for the request's
organization. If the cookie still knows an account and the RP passed no
`login_hint`, the user is sent to `/accounts` instead of `/loginname`.
`prompt=none` is excluded since it must never render UI.
- `/accounts` synthesizes an (invalid) `Session` for each cookie entry
that has no live counterpart, so the account is still listed and a click
re-authenticates via `/loginname` → password in one step.
- `clearSession` prunes the cookie entry when the server reports the
session as already gone (`NotFound` / `PermissionDenied`), so stale
cards can be dismissed. Any other error is rethrown so a still-valid
server session is never silently orphaned.
- `getAllSessionCookieIds` is now a thin wrapper over `getAllSessions`;
`FlowInitiationParams.sessionCookies` is typed as `Cookie[]`.
- Tests: new `describe` block covering the cookie fallback, `login_hint`
precedence, `prompt=none`, org-scope matching/mismatch, and
`SELECT_ACCOUNT` / default prompt with live sessions filtered out by
org.
- Also adds `.claude/`, `.cursor/`, `.codex/`, `.windsurf/`,
`.copilot/`, `.aider*` to `.gitignore`.

---------

Co-authored-by: conblem <[email protected]>
This commit is contained in:
Max Peintner
2026-08-27 11:07:30 +00:00
committed by GitHub
co-authored by conblem
parent 6128aeaa89
commit 3c746281f1
25 changed files with 708 additions and 259 deletions
+6
View File
@@ -104,6 +104,12 @@ go.work.sum
CLAUDE.md CLAUDE.md
.mcp.json .mcp.json
.gemini/* .gemini/*
.claude/
.cursor/
.codex/
.windsurf/
.copilot/
.aider*
docs_old docs_old
apps/docs/package-lock.json apps/docs/package-lock.json
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "انتهت صلاحية جلستك الحالية. يرجى تسجيل الدخول مرة أخرى.", "sessionExpired": "انتهت صلاحية جلستك الحالية. يرجى تسجيل الدخول مرة أخرى.",
"failedLoading": "فشل تحميل البيانات. يرجى المحاولة مرة أخرى.", "failedLoading": "فشل تحميل البيانات. يرجى المحاولة مرة أخرى.",
"tryagain": "حاول مرة أخرى", "tryagain": "حاول مرة أخرى",
"couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة" "couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة",
"couldNotClearSession": "تعذر إنهاء الجلسة. يرجى المحاولة مرة أخرى."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"tryagain": "Erneut versuchen", "tryagain": "Erneut versuchen",
"couldNotContinueSession": "Sitzung konnte nicht fortgesetzt werden - fehlende Benutzerinformationen" "couldNotContinueSession": "Sitzung konnte nicht fortgesetzt werden - fehlende Benutzerinformationen",
"couldNotClearSession": "Die Sitzung konnte nicht beendet werden. Bitte versuchen Sie es erneut."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Your current session has expired. Please login again.", "sessionExpired": "Your current session has expired. Please login again.",
"failedLoading": "Failed to load data. Please try again.", "failedLoading": "Failed to load data. Please try again.",
"tryagain": "Try Again", "tryagain": "Try Again",
"couldNotContinueSession": "Could not continue with session - missing user information" "couldNotContinueSession": "Could not continue with session - missing user information",
"couldNotClearSession": "Could not end the session. Please try again."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.",
"failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.",
"tryagain": "Intentar de nuevo", "tryagain": "Intentar de nuevo",
"couldNotContinueSession": "No se pudo continuar con la sesión - falta información del usuario" "couldNotContinueSession": "No se pudo continuar con la sesión - falta información del usuario",
"couldNotClearSession": "No se pudo cerrar la sesión. Inténtelo de nuevo."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Votre session actuelle a expiré. Veuillez vous reconnecter.", "sessionExpired": "Votre session actuelle a expiré. Veuillez vous reconnecter.",
"failedLoading": "Échec du chargement des données. Veuillez réessayer.", "failedLoading": "Échec du chargement des données. Veuillez réessayer.",
"tryagain": "Réessayer", "tryagain": "Réessayer",
"couldNotContinueSession": "Impossible de continuer la session - informations utilisateur manquantes" "couldNotContinueSession": "Impossible de continuer la session - informations utilisateur manquantes",
"couldNotClearSession": "La session n'a pas pu être terminée. Veuillez réessayer."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "A munkameneted lejárt. Kérjük, jelentkezz be újra.", "sessionExpired": "A munkameneted lejárt. Kérjük, jelentkezz be újra.",
"failedLoading": "Nem sikerült betölteni az adatokat. Kérjük, próbáld újra.", "failedLoading": "Nem sikerült betölteni az adatokat. Kérjük, próbáld újra.",
"tryagain": "Próbáld újra", "tryagain": "Próbáld újra",
"couldNotContinueSession": "Nem sikerült folytatni a munkamenetet - hiányzó felhasználói információk" "couldNotContinueSession": "Nem sikerült folytatni a munkamenetet - hiányzó felhasználói információk",
"couldNotClearSession": "A munkamenetet nem sikerült befejezni. Kérjük, próbálja újra."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.",
"failedLoading": "Impossibile caricare i dati. Riprova.", "failedLoading": "Impossibile caricare i dati. Riprova.",
"tryagain": "Riprova", "tryagain": "Riprova",
"couldNotContinueSession": "Impossibile continuare con la sessione - informazioni utente mancanti" "couldNotContinueSession": "Impossibile continuare con la sessione - informazioni utente mancanti",
"couldNotClearSession": "Impossibile terminare la sessione. Riprova."
} }
} }
+2 -1
View File
@@ -450,7 +450,8 @@
"sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。", "sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。",
"failedLoading": "データの読み込みに失敗しました。再試行してください。", "failedLoading": "データの読み込みに失敗しました。再試行してください。",
"tryagain": "再試行", "tryagain": "再試行",
"couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています" "couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています",
"couldNotClearSession": "セッションを終了できませんでした。もう一度お試しください。"
}, },
"zitadel": { "zitadel": {
"errors": { "errors": {
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Uw huidige sessie is verlopen. Log opnieuw in.", "sessionExpired": "Uw huidige sessie is verlopen. Log opnieuw in.",
"failedLoading": "Gegevens laden mislukt. Probeer het opnieuw.", "failedLoading": "Gegevens laden mislukt. Probeer het opnieuw.",
"tryagain": "Opnieuw proberen", "tryagain": "Opnieuw proberen",
"couldNotContinueSession": "Kon de sessie niet voortzetten - ontbrekende gebruikersinformatie" "couldNotContinueSession": "Kon de sessie niet voortzetten - ontbrekende gebruikersinformatie",
"couldNotClearSession": "De sessie kon niet worden beëindigd. Probeer het opnieuw."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.",
"failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.",
"tryagain": "Spróbuj ponownie", "tryagain": "Spróbuj ponownie",
"couldNotContinueSession": "Nie udało się kontynuować sesji - brak informacji o użytkowniku" "couldNotContinueSession": "Nie udało się kontynuować sesji - brak informacji o użytkowniku",
"couldNotClearSession": "Nie udało się zakończyć sesji. Spróbuj ponownie."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Sua sessão atual expirou. Por favor, faça login novamente.", "sessionExpired": "Sua sessão atual expirou. Por favor, faça login novamente.",
"failedLoading": "Falha ao carregar dados. Por favor, tente novamente.", "failedLoading": "Falha ao carregar dados. Por favor, tente novamente.",
"tryagain": "Tentar Novamente", "tryagain": "Tentar Novamente",
"couldNotContinueSession": "Não foi possível continuar a sessão — informações do usuário ausentes" "couldNotContinueSession": "Não foi possível continuar a sessão — informações do usuário ausentes",
"couldNotClearSession": "Não foi possível terminar a sessão. Tente novamente."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Ваша сессия истекла. Войдите снова.", "sessionExpired": "Ваша сессия истекла. Войдите снова.",
"failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.",
"tryagain": "Попробовать снова", "tryagain": "Попробовать снова",
"couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе" "couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе",
"couldNotClearSession": "Не удалось завершить сеанс. Пожалуйста, попробуйте ещё раз."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Mevcut oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.", "sessionExpired": "Mevcut oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.",
"failedLoading": "Veri yüklenemedi. Lütfen tekrar deneyin.", "failedLoading": "Veri yüklenemedi. Lütfen tekrar deneyin.",
"tryagain": "Tekrar Dene", "tryagain": "Tekrar Dene",
"couldNotContinueSession": "Oturuma devam edilemedi - kullanıcı bilgileri eksik" "couldNotContinueSession": "Oturuma devam edilemedi - kullanıcı bilgileri eksik",
"couldNotClearSession": "Oturum sonlandırılamadı. Lütfen tekrar deneyin."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "Ваш поточний сеанс закінчився. Будь ласка, увійдіть знову.", "sessionExpired": "Ваш поточний сеанс закінчився. Будь ласка, увійдіть знову.",
"failedLoading": "Не вдалося завантажити дані. Будь ласка, спробуйте ще раз.", "failedLoading": "Не вдалося завантажити дані. Будь ласка, спробуйте ще раз.",
"tryagain": "Спробувати ще раз", "tryagain": "Спробувати ще раз",
"couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача" "couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача",
"couldNotClearSession": "Не вдалося завершити сеанс. Будь ласка, спробуйте ще раз."
} }
} }
+2 -1
View File
@@ -457,6 +457,7 @@
"sessionExpired": "当前会话已过期,请重新登录。", "sessionExpired": "当前会话已过期,请重新登录。",
"failedLoading": "加载数据失败,请再试一次。", "failedLoading": "加载数据失败,请再试一次。",
"tryagain": "重试", "tryagain": "重试",
"couldNotContinueSession": "无法继续会话 - 缺少用户信息" "couldNotContinueSession": "无法继续会话 - 缺少用户信息",
"couldNotClearSession": "无法结束会话。请重试。"
} }
} }
+42 -12
View File
@@ -1,11 +1,13 @@
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { SessionsList } from "@/components/sessions-list"; import { SessionsList } from "@/components/sessions-list";
import { Translated } from "@/components/translated"; import { Translated } from "@/components/translated";
import { getAllSessionCookieIds } from "@/lib/cookies"; import { getAllSessions } from "@/lib/cookies";
import { getServiceConfig } from "@/lib/service-url"; import { getServiceConfig } from "@/lib/service-url";
import { getBrandingSettings, getDefaultOrg, listSessions, ServiceConfig } from "@/lib/zitadel"; import { getBrandingSettings, getDefaultOrg, listSessions, ServiceConfig } from "@/lib/zitadel";
import { UserPlusIcon } from "@heroicons/react/24/outline"; import { UserPlusIcon } from "@heroicons/react/24/outline";
import { create } from "@zitadel/client";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { Session, SessionSchema } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Metadata } from "next"; import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
// import { getLocale } from "next-intl/server"; // import { getLocale } from "next-intl/server";
@@ -18,24 +20,52 @@ export async function generateMetadata(): Promise<Metadata> {
} }
async function loadSessions({ serviceConfig, organization }: { serviceConfig: ServiceConfig; organization?: string }) { async function loadSessions({ serviceConfig, organization }: { serviceConfig: ServiceConfig; organization?: string }) {
const cookieIds = await getAllSessionCookieIds(); // Deliberately no cleanup: expired cookie entries are still listed (as invalid
// accounts) so the user can re-authenticate with one click, like Login V1.
const sessionCookies = await getAllSessions();
if (cookieIds && cookieIds.length) { if (!sessionCookies || !sessionCookies.length) {
const response = await listSessions({ console.info("No session cookie found.");
serviceConfig, return [];
ids: cookieIds.filter((id) => !!id) as string[], }
});
let sessions = response?.sessions ?? []; // listSessions is a plain search: ids of terminated or unknown sessions are
// simply absent from the response, it does not fail. Transport failures are
// deliberately not caught here, they must surface instead of downgrading
// live sessions to invalid cards.
const ids = sessionCookies.map((s) => s.id).filter((id) => !!id) as string[];
let liveSessions: Session[] = [];
if (ids.length) {
const response = await listSessions({ serviceConfig, ids });
liveSessions = response?.sessions ?? [];
}
// For cookie entries whose server-side session no longer exists (e.g. after
// an RP-initiated logout) synthesize an invalid Session so the account stays
// selectable and the user can re-authenticate with one click.
const liveIds = new Set(liveSessions.map((s) => s.id));
const synthesized: Session[] = sessionCookies
.filter((c) => !!c.id && !!c.loginName && !liveIds.has(c.id))
.map((c) =>
create(SessionSchema, {
id: c.id,
factors: {
user: {
loginName: c.loginName,
// No displayName: the cookie does not carry one, and reusing the
// loginName would render it twice and break the avatar initials.
organizationId: c.organization ?? "",
},
},
}),
);
let sessions = [...liveSessions, ...synthesized];
if (organization) { if (organization) {
sessions = sessions.filter((s) => s.factors?.user?.organizationId === organization); sessions = sessions.filter((s) => s.factors?.user?.organizationId === organization);
} }
return sessions; return sessions;
} else {
console.info("No session cookie found.");
return [];
}
} }
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) { export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
@@ -4,9 +4,10 @@ import { clearSession } from "@/lib/server/session";
import { timestampDate } from "@zitadel/client"; import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import moment from "moment"; import moment from "moment";
import { useLocale } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "./alert";
import { Avatar } from "./avatar"; import { Avatar } from "./avatar";
import { isSessionPrimaryFactorAndLifetimeValid } from "./session-item"; import { isSessionPrimaryFactorAndLifetimeValid } from "./session-item";
import { Translated } from "./translated"; import { Translated } from "./translated";
@@ -14,38 +15,46 @@ import { Translated } from "./translated";
export function SessionClearItem({ session, reload }: { session: Session; reload: () => void }) { export function SessionClearItem({ session, reload }: { session: Session; reload: () => void }) {
const currentLocale = useLocale(); const currentLocale = useLocale();
moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale);
const t = useTranslations("error");
const [_loading, setLoading] = useState<boolean>(false); const [_loading, setLoading] = useState<boolean>(false);
async function clearSessionId(id: string) { /**
* Returns true when the session was removed (server-side and from the cookie).
* On failure the error is shown and the card must stay in the list.
*/
async function clearSessionId(id: string): Promise<boolean> {
setLoading(true); setLoading(true);
const response = await clearSession({ setError(null);
sessionId: id, try {
}) const response = await clearSession({ sessionId: id });
.catch((error) => { if (response && "error" in response && response.error) {
setError(error.message); setError(response.error);
return; return false;
}) }
.finally(() => { return true;
} catch {
setError(t("couldNotClearSession"));
return false;
} finally {
setLoading(false); setLoading(false);
}); }
return response;
} }
const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session); const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session);
const [_error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// TODO: To we have to call this? // TODO: To we have to call this?
useRouter(); useRouter();
return ( return (
<>
<button <button
onClick={async () => { onClick={async () => {
clearSessionId(session.id).then(() => { if (await clearSessionId(session.id)) {
reload(); reload();
}); }
}} }}
className="group border-divider-light bg-background-light-400 dark:bg-background-dark-400 flex flex-row items-center rounded-md border px-4 py-2 transition-all hover:shadow-lg dark:hover:bg-white/10" className="group border-divider-light bg-background-light-400 dark:bg-background-dark-400 flex flex-row items-center rounded-md border px-4 py-2 transition-all hover:shadow-lg dark:hover:bg-white/10"
> >
@@ -92,5 +101,7 @@ export function SessionClearItem({ session, reload }: { session: Session; reload
)} )}
</div> </div>
</button> </button>
{error && <Alert>{error}</Alert>}
</>
); );
} }
+27 -15
View File
@@ -8,9 +8,10 @@ import * as Tooltip from "@radix-ui/react-tooltip";
import { Timestamp, timestampDate } from "@zitadel/client"; import { Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import moment from "moment"; import moment from "moment";
import { useLocale } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useState } from "react"; import React, { useState } from "react";
import { Alert } from "./alert";
import { AutoSubmitForm } from "./auto-submit-form"; import { AutoSubmitForm } from "./auto-submit-form";
import { Avatar } from "./avatar"; import { Avatar } from "./avatar";
import { Translated } from "./translated"; import { Translated } from "./translated";
@@ -34,33 +35,41 @@ export function isSessionPrimaryFactorAndLifetimeValid(session: Partial<Session>
export function SessionItem({ session, reload, requestId }: { session: Session; reload: () => void; requestId?: string }) { export function SessionItem({ session, reload, requestId }: { session: Session; reload: () => void; requestId?: string }) {
const currentLocale = useLocale(); const currentLocale = useLocale();
moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale);
const t = useTranslations("error");
const [_loading, setLoading] = useState<boolean>(false); const [_loading, setLoading] = useState<boolean>(false);
async function clearSessionId(id: string) { /**
* Returns true when the session was removed (server-side and from the cookie).
* On failure the error is shown and the card must stay in the list.
*/
async function clearSessionId(id: string): Promise<boolean> {
setLoading(true); setLoading(true);
const response = await clearSession({ setError(null);
sessionId: id, try {
}) const response = await clearSession({ sessionId: id });
.catch((error) => { if (response && "error" in response && response.error) {
setError(error.message); setError(response.error);
return; return false;
}) }
.finally(() => { return true;
} catch {
setError(t("couldNotClearSession"));
return false;
} finally {
setLoading(false); setLoading(false);
}); }
return response;
} }
const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session); const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null); const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const [_error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
return ( return (
<>
<Tooltip.Root delayDuration={300}> <Tooltip.Root delayDuration={300}>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />} {samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
@@ -133,8 +142,9 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
onClick={async (event: React.MouseEvent) => { onClick={async (event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await clearSessionId(session.id); if (await clearSessionId(session.id)) {
reload(); reload();
}
}} }}
/> />
</div> </div>
@@ -152,5 +162,7 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
</Tooltip.Portal> </Tooltip.Portal>
)} )}
</Tooltip.Root> </Tooltip.Root>
{error && <Alert>{error}</Alert>}
</>
); );
} }
@@ -3,6 +3,7 @@
import { clearSession } from "@/lib/server/session"; import { clearSession } from "@/lib/server/session";
import { timestampDate } from "@zitadel/client"; import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { useTranslations } from "next-intl";
import { redirect, useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Alert, AlertType } from "./alert"; import { Alert, AlertType } from "./alert";
@@ -18,7 +19,9 @@ type Props = {
export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, organization }: Props) { export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, organization }: Props) {
const [list, setList] = useState<Session[]>(sessions); const [list, setList] = useState<Session[]>(sessions);
const [error, setError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const t = useTranslations("error");
const clearHintedSession = useCallback(async () => { const clearHintedSession = useCallback(async () => {
console.log("Clearing session for login hint:", logoutHint); console.log("Clearing session for login hint:", logoutHint);
@@ -28,15 +31,22 @@ export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri,
})?.id; })?.id;
if (sessionIdToBeCleared) { if (sessionIdToBeCleared) {
const clearSessionResponse = await clearSession({ let clearError: string | undefined;
sessionId: sessionIdToBeCleared, try {
}).catch((error) => { const clearSessionResponse = await clearSession({ sessionId: sessionIdToBeCleared });
if (clearSessionResponse && "error" in clearSessionResponse) {
clearError = clearSessionResponse.error;
}
} catch (error) {
console.error("Error clearing session:", error); console.error("Error clearing session:", error);
return; clearError = t("couldNotClearSession");
}); }
if (!clearSessionResponse) { // Do not tell the RP the logout completed when the session was kept:
console.error("Failed to clear session for login hint:", logoutHint); // show the error and leave the card so the user can retry.
if (clearError) {
setError(clearError);
return;
} }
if (postLogoutRedirectUri) { if (postLogoutRedirectUri) {
@@ -53,7 +63,7 @@ export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri,
} else { } else {
console.warn(`No session found for login hint: ${logoutHint}`); console.warn(`No session found for login hint: ${logoutHint}`);
} }
}, [logoutHint, sessions, postLogoutRedirectUri, organization, router]); }, [logoutHint, sessions, postLogoutRedirectUri, organization, router, t]);
useEffect(() => { useEffect(() => {
if (logoutHint) { if (logoutHint) {
@@ -86,6 +96,7 @@ export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri,
/> />
); );
})} })}
{error && <Alert>{error}</Alert>}
{list.length === 0 && ( {list.length === 0 && (
<Alert type={AlertType.INFO}> <Alert type={AlertType.INFO}>
<Translated i18nKey="noResults" namespace="logout" /> <Translated i18nKey="noResults" namespace="logout" />
+4 -21
View File
@@ -231,34 +231,17 @@ export async function getSessionCookieByLoginName<T>({
/** /**
* *
* @param cleanup when true, removes all expired sessions, default true * @param cleanup when true, excludes expired sessions from the result, default false
* @returns Session Cookies * @returns Session Cookies
*/ */
export async function getAllSessionCookieIds<T>(cleanup: boolean = false): Promise<string[]> { export async function getAllSessionCookieIds<T>(cleanup: boolean = false): Promise<string[]> {
const cookiesList = await cookies(); const sessions = await getAllSessions<T>(cleanup);
const stringifiedCookie = cookiesList.get("sessions"); return sessions.map(({ id }) => id);
if (!stringifiedCookie?.value) {
return [];
}
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
if (cleanup) {
const now = new Date();
return sessions
.filter((session) =>
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
)
.map((session) => session.id);
}
return sessions.map((session) => session.id);
} }
/** /**
* *
* @param cleanup when true, removes all expired sessions, default true * @param cleanup when true, excludes expired sessions from the result, default false
* @returns Session Cookies * @returns Session Cookies
*/ */
export async function getAllSessions<T>(cleanup: boolean = false): Promise<SessionCookie<T>[]> { export async function getAllSessions<T>(cleanup: boolean = false): Promise<SessionCookie<T>[]> {
+200 -10
View File
@@ -1,3 +1,4 @@
import type { Cookie } from "@/lib/cookies";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FlowInitiationParams, handleOIDCFlowInitiation } from "./flow-initiation"; import { FlowInitiationParams, handleOIDCFlowInitiation } from "./flow-initiation";
@@ -51,6 +52,10 @@ function makeRequest(url = "https://example.com/login?requestId=oidc_abc123"): N
return new NextRequest(url); return new NextRequest(url);
} }
function makeCookie(id: string, overrides?: Partial<Cookie>): Cookie {
return { id, token: "tok", loginName: "", creationTs: "", expirationTs: "", changeTs: "", ...overrides };
}
function makeBaseParams(overrides?: Partial<FlowInitiationParams>): FlowInitiationParams { function makeBaseParams(overrides?: Partial<FlowInitiationParams>): FlowInitiationParams {
return { return {
serviceConfig: { baseUrl: "https://api.example.com" } as any, serviceConfig: { baseUrl: "https://api.example.com" } as any,
@@ -243,7 +248,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [otherOrgSession] as any, sessions: [otherOrgSession] as any,
sessionCookies: [{ id: "session-other", token: "tok" }], sessionCookies: [makeCookie("session-other")],
}), }),
); );
@@ -270,7 +275,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [otherOrgSession] as any, sessions: [otherOrgSession] as any,
sessionCookies: [{ id: "session-other", token: "tok" }], sessionCookies: [makeCookie("session-other")],
}), }),
); );
@@ -296,7 +301,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [otherOrgSession] as any, sessions: [otherOrgSession] as any,
sessionCookies: [{ id: "session-other", token: "tok" }], sessionCookies: [makeCookie("session-other")],
}), }),
); );
@@ -321,7 +326,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [orgSession] as any, sessions: [orgSession] as any,
sessionCookies: [{ id: "session-org", token: "tok" }], sessionCookies: [makeCookie("session-org")],
}), }),
); );
@@ -351,7 +356,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [otherOrgSession] as any, sessions: [otherOrgSession] as any,
sessionCookies: [{ id: "session-other", token: "tok" }], sessionCookies: [makeCookie("session-other")],
}), }),
); );
@@ -378,7 +383,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [otherOrgSession] as any, sessions: [otherOrgSession] as any,
sessionCookies: [{ id: "session-other", token: "tok" }], sessionCookies: [makeCookie("session-other")],
}), }),
); );
@@ -405,7 +410,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [otherOrgSession] as any, sessions: [otherOrgSession] as any,
sessionCookies: [{ id: "session-other", token: "tok" }], sessionCookies: [makeCookie("session-other")],
}), }),
); );
@@ -430,7 +435,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
const res = await handleOIDCFlowInitiation( const res = await handleOIDCFlowInitiation(
makeBaseParams({ makeBaseParams({
sessions: [orgSession] as any, sessions: [orgSession] as any,
sessionCookies: [{ id: "session-org", token: "tok" }], sessionCookies: [makeCookie("session-org")],
}), }),
); );
@@ -646,6 +651,191 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
}); });
}); });
describe("handleOIDCFlowInitiation — stale session cookie fallback (#12252)", () => {
let mockGetAuthRequest: ReturnType<typeof vi.fn>;
let mockConstructUrl: ReturnType<typeof vi.fn>;
let mockFindValidSession: ReturnType<typeof vi.fn>;
let mockSendLoginname: ReturnType<typeof vi.fn>;
// Cookie entries left behind after an RP-initiated logout terminated the
// server-side sessions: no live Session objects, but loginName is still known.
const staleOrgCookie = makeCookie("session-stale-org", { loginName: "[email protected]", organization: "111111" });
const staleOtherOrgCookie = makeCookie("session-stale-other", { loginName: "[email protected]", organization: "999999" });
const otherOrgSession = {
id: "session-other",
factors: { user: { id: "user2", organizationId: "999999", loginName: "[email protected]" } },
};
function authRequestWith(overrides: Record<string, unknown>) {
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: [],
prompt: [],
loginHint: undefined,
...overrides,
},
});
}
beforeEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
const zitadel = await import("@/lib/zitadel");
const serviceUrl = await import("@/lib/service-url");
const session = await import("@/lib/session");
const authUtils = await import("@/lib/auth-utils");
const loginname = await import("@/lib/server/loginname");
mockGetAuthRequest = vi.mocked(zitadel.getAuthRequest);
mockConstructUrl = vi.mocked(serviceUrl.constructUrl);
mockFindValidSession = vi.mocked(session.findValidSession);
mockSendLoginname = vi.mocked(loginname.sendLoginname);
vi.mocked(zitadel.getLoginSettings).mockResolvedValue(undefined);
vi.mocked(zitadel.getSecuritySettings).mockResolvedValue(undefined);
vi.mocked(authUtils.getValidLocaleFromUILocales).mockReturnValue(null);
mockConstructUrl.mockImplementation((_req: any, path: string) => {
return new URL(`https://example.com${path}`);
});
mockFindValidSession.mockResolvedValue(null);
mockSendLoginname.mockResolvedValue(undefined);
});
afterEach(() => {
vi.unstubAllEnvs();
});
test("should redirect to /accounts when there are no live sessions but the cookie references an account", async () => {
authRequestWith({});
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [], sessionCookies: [staleOrgCookie] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/accounts");
expect(location).not.toContain("/loginname");
});
test("should ignore cookie entries without a loginName", async () => {
authRequestWith({});
const res = await handleOIDCFlowInitiation(
makeBaseParams({ sessions: [], sessionCookies: [{ ...staleOrgCookie, loginName: "" }] }),
);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/loginname");
});
test("should prefer login_hint over the cookie fallback when there are no live sessions", async () => {
authRequestWith({ loginHint: "[email protected]" });
mockSendLoginname.mockResolvedValue({ redirect: "/password?requestId=oidc_abc123" });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [], sessionCookies: [staleOrgCookie] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/password");
expect(location).not.toContain("/accounts");
});
test("should not show the account picker for prompt=none even if the cookie references an account", async () => {
const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb");
authRequestWith({ prompt: [Prompt.NONE] });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [], sessionCookies: [staleOrgCookie] }));
const location = res.headers.get("location") ?? "";
expect(location).not.toContain("/accounts");
});
test("should redirect to /loginname when the cookie account belongs to a different org than the org scope", async () => {
authRequestWith({ scope: ["urn:zitadel:iam:org:id:111111"] });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [], sessionCookies: [staleOtherOrgCookie] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/loginname");
expect(location).not.toContain("/accounts");
});
test("should redirect to /accounts when the cookie account matches the org scope", async () => {
authRequestWith({ scope: ["urn:zitadel:iam:org:id:111111"] });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [], sessionCookies: [staleOrgCookie] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/accounts");
expect(location).toContain("organization=111111");
});
test("should keep /accounts for SELECT_ACCOUNT when live sessions are filtered out by org but the cookie has a matching account", async () => {
const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb");
authRequestWith({ scope: ["urn:zitadel:iam:org:id:111111"], prompt: [Prompt.SELECT_ACCOUNT] });
const res = await handleOIDCFlowInitiation(
makeBaseParams({
sessions: [otherOrgSession] as any,
sessionCookies: [{ ...staleOtherOrgCookie, id: "session-other" }, staleOrgCookie],
}),
);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/accounts");
});
test("should not show the account picker for prompt=login even if the cookie references an account", async () => {
const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb");
authRequestWith({ prompt: [Prompt.LOGIN] });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [], sessionCookies: [staleOrgCookie] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/loginname");
expect(location).not.toContain("/accounts");
});
test("should let login_hint win over the cookie fallback for SELECT_ACCOUNT when no live session is eligible", async () => {
const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb");
authRequestWith({
scope: ["urn:zitadel:iam:org:id:111111"],
prompt: [Prompt.SELECT_ACCOUNT],
loginHint: "[email protected]",
});
const res = await handleOIDCFlowInitiation(
makeBaseParams({
sessions: [otherOrgSession] as any,
sessionCookies: [
makeCookie("session-other", { loginName: "[email protected]", organization: "999999" }),
staleOrgCookie,
],
}),
);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/loginname");
expect(location).toContain("loginName=hinted%40org.com");
expect(location).not.toContain("/accounts");
});
test("should keep /accounts (default prompt) when live sessions are filtered out by org but the cookie has a matching account", async () => {
authRequestWith({ scope: ["urn:zitadel:iam:org:id:111111"] });
const res = await handleOIDCFlowInitiation(
makeBaseParams({
sessions: [otherOrgSession] as any,
sessionCookies: [{ ...staleOtherOrgCookie, id: "session-other" }, staleOrgCookie],
}),
);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/accounts");
});
});
describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix", () => { describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix", () => {
let mockGetAuthRequest: ReturnType<typeof vi.fn>; let mockGetAuthRequest: ReturnType<typeof vi.fn>;
let mockConstructUrl: ReturnType<typeof vi.fn>; let mockConstructUrl: ReturnType<typeof vi.fn>;
@@ -700,7 +890,7 @@ describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix
makeBaseParams({ makeBaseParams({
requestId: "oidc_abc123", requestId: "oidc_abc123",
sessions: [existingSession] as any, sessions: [existingSession] as any,
sessionCookies: [{ id: "session-1", token: "tok" }], sessionCookies: [makeCookie("session-1")],
}), }),
); );
@@ -733,7 +923,7 @@ describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix
makeBaseParams({ makeBaseParams({
requestId: "oidc_abc123", requestId: "oidc_abc123",
sessions: [existingSession] as any, sessions: [existingSession] as any,
sessionCookies: [{ id: "session-1", token: "tok" }], sessionCookies: [makeCookie("session-1")],
}), }),
); );
+42 -3
View File
@@ -1,6 +1,6 @@
import { getValidLocaleFromUILocales } from "@/lib/auth-utils"; import { getValidLocaleFromUILocales } from "@/lib/auth-utils";
import { isExternalUrl, isSafeRedirectUri } from "@/lib/client-utils"; import { isExternalUrl, isSafeRedirectUri } from "@/lib/client-utils";
import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies"; import { getLanguageCookie, setLanguageCookie, type Cookie } from "@/lib/cookies";
import { shouldUILocalesOverrideCookie } from "@/lib/i18n"; import { shouldUILocalesOverrideCookie } from "@/lib/i18n";
import { idpTypeToSlug } from "@/lib/idp"; import { idpTypeToSlug } from "@/lib/idp";
@@ -222,11 +222,22 @@ function getEligibleSessions(sessions: Session[], organization?: string): Sessio
return sessions.filter((s) => s.factors?.user?.organizationId === organization); return sessions.filter((s) => s.factors?.user?.organizationId === organization);
} }
/**
* Whether the `sessions` cookie references any previous account for the given
* organization (or for any org when `organization` is unset).
*/
function hasCookieAccount(sessionCookies: Cookie[], organization?: string): boolean {
if (!sessionCookies || sessionCookies.length === 0) {
return false;
}
return sessionCookies.some((c) => !!c.loginName && (!organization || c.organization === organization));
}
export interface FlowInitiationParams { export interface FlowInitiationParams {
serviceConfig: ServiceConfig; serviceConfig: ServiceConfig;
requestId: string; requestId: string;
sessions: Session[]; sessions: Session[];
sessionCookies: any[]; sessionCookies: Cookie[];
request: NextRequest; request: NextRequest;
} }
@@ -366,6 +377,11 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
if (eligibleSessions.length === 0) { if (eligibleSessions.length === 0) {
// No live session is eligible. If the session cookie still references
// a previous account (and the RP did not pass a login_hint, which always
// takes precedence over the cookie fallback), keep account selection so
// the user can pick it.
if (authRequest.loginHint || !hasCookieAccount(sessionCookies, organization)) {
return gotoLoginname({ return gotoLoginname({
request, request,
requestId, requestId,
@@ -374,6 +390,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
orgDomain, orgDomain,
}); });
} }
}
return gotoAccounts({ return gotoAccounts({
request, request,
requestId, requestId,
@@ -464,7 +481,9 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
// Prefill loginname (not the account picker) for an unresolved hint or when // Prefill loginname (not the account picker) for an unresolved hint or when
// no session is eligible: the picker would only list non-matching accounts. // no session is eligible: the picker would only list non-matching accounts.
if (authRequest.loginHint || eligibleSessions.length === 0) { // Exception: if the sessions cookie still references a previous account for
// this organization, keep the picker so the user can select it (#12252).
if (authRequest.loginHint || (eligibleSessions.length === 0 && !hasCookieAccount(sessionCookies, organization))) {
return gotoLoginname({ return gotoLoginname({
request, request,
requestId, requestId,
@@ -534,6 +553,26 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
} }
} }
} else { } else {
// No live sessions were resolved. If the sessions cookie still references a
// previous account and the RP did not pass a login_hint, show account
// selection (mirrors Login V1 behavior after RP-initiated logout, #12252).
// prompt=none must never render UI and prompt=login goes straight to
// /loginname like it does when live sessions exist, so both keep the
// existing behavior.
if (
!authRequest?.loginHint &&
!authRequest?.prompt.includes(Prompt.NONE) &&
!authRequest?.prompt.includes(Prompt.LOGIN) &&
hasCookieAccount(sessionCookies, organization)
) {
return gotoAccounts({
request,
requestId,
organization,
orgDomain,
});
}
// No session: resolve a login_hint straight to the next step if we can. // No session: resolve a login_hint straight to the next step if we can.
const hintResponse = await resolveLoginHint({ const hintResponse = await resolveLoginHint({
request, request,
+130
View File
@@ -0,0 +1,130 @@
import { Code } from "@connectrpc/connect";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { clearSession } from "./session";
vi.mock("@zitadel/client", async () => {
const { Code } = await import("@connectrpc/connect");
return { Code, create: vi.fn((_schema: unknown, data: unknown) => data), Duration: {} };
});
vi.mock("@/lib/zitadel", () => ({
deleteSession: vi.fn(),
getLoginSettings: vi.fn(),
getSecuritySettings: vi.fn(),
humanMFAInitSkipped: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
listUsers: vi.fn(),
}));
vi.mock("@/lib/server/cookie", () => ({
createSessionAndUpdateCookie: vi.fn(),
setSessionAndUpdateCookie: vi.fn(),
}));
vi.mock("../cookies", () => ({
getMostRecentSessionCookie: vi.fn(),
getSessionCookieById: vi.fn(),
getSessionCookieByLoginName: vi.fn(),
removeSessionFromCookie: vi.fn(),
}));
vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(() => ({ serviceConfig: { baseUrl: "https://example.com" } })),
}));
vi.mock("../client", () => ({
completeFlowOrGetUrl: vi.fn(),
}));
vi.mock("../session", () => ({
isSessionValid: vi.fn(),
}));
vi.mock("./host", () => ({
getPublicHost: vi.fn(() => "test.com"),
}));
vi.mock("./loginname", () => ({
sendLoginname: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(async () => (key: string) => key),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(() => new Headers()),
}));
vi.mock("@/lib/grpc/interceptors/error-classification", () => ({
isClassifiedError: (error: unknown) => error !== null && typeof error === "object" && "code" in error,
}));
const cookie = { id: "session-1", token: "token-1", loginName: "[email protected]" };
describe("clearSession", () => {
let deleteSession: any;
let getSecuritySettings: any;
let getSessionCookieById: any;
let removeSessionFromCookie: any;
beforeEach(async () => {
vi.clearAllMocks();
const zitadel = await import("@/lib/zitadel");
const cookies = await import("../cookies");
deleteSession = vi.mocked(zitadel.deleteSession);
getSecuritySettings = vi.mocked(zitadel.getSecuritySettings);
getSessionCookieById = vi.mocked(cookies.getSessionCookieById);
removeSessionFromCookie = vi.mocked(cookies.removeSessionFromCookie);
getSessionCookieById.mockResolvedValue(cookie);
getSecuritySettings.mockResolvedValue({ embeddedIframe: { enabled: true } });
});
test("deletes the session and prunes the cookie entry on success", async () => {
deleteSession.mockResolvedValue({ details: {} });
const res = await clearSession({ sessionId: "session-1" });
expect(res).toBeUndefined();
expect(deleteSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-1", sessionToken: "token-1" }));
expect(removeSessionFromCookie).toHaveBeenCalledWith({ session: cookie, iFrameEnabled: true });
});
test("prunes the cookie entry when the server rejects the cookie token (session gone or token stale)", async () => {
deleteSession.mockRejectedValue({ code: Code.PermissionDenied });
const res = await clearSession({ sessionId: "session-1" });
expect(res).toBeUndefined();
expect(removeSessionFromCookie).toHaveBeenCalledWith({ session: cookie, iFrameEnabled: true });
});
test("keeps the cookie entry and reports an error on any other failure", async () => {
deleteSession.mockRejectedValue({ code: Code.Unavailable });
const res = await clearSession({ sessionId: "session-1" });
expect(res).toEqual({ error: "couldNotClearSession" });
expect(removeSessionFromCookie).not.toHaveBeenCalled();
});
test("attempts the server-side delete even when security settings cannot be loaded", async () => {
deleteSession.mockResolvedValue({ details: {} });
getSecuritySettings.mockRejectedValue(new Error("settings unavailable"));
await expect(clearSession({ sessionId: "session-1" })).rejects.toThrow("settings unavailable");
expect(deleteSession).toHaveBeenCalledTimes(1);
expect(removeSessionFromCookie).not.toHaveBeenCalled();
});
test("does nothing when the session id is not in the cookie", async () => {
getSessionCookieById.mockResolvedValue(undefined);
const res = await clearSession({ sessionId: "unknown" });
expect(res).toBeUndefined();
expect(deleteSession).not.toHaveBeenCalled();
});
});
+29 -7
View File
@@ -11,7 +11,7 @@ import {
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
listUsers, listUsers,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create, Duration } from "@zitadel/client"; import { Code, create, Duration } from "@zitadel/client";
import { Challenges, RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; import { Challenges, RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
@@ -294,7 +294,7 @@ type ClearSessionOptions = {
sessionId: string; sessionId: string;
}; };
export async function clearSession(options: ClearSessionOptions) { export async function clearSession(options: ClearSessionOptions): Promise<{ error?: string } | void> {
const _headers = await headers(); const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers); const { serviceConfig } = getServiceConfig(_headers);
@@ -306,18 +306,40 @@ export async function clearSession(options: ClearSessionOptions) {
return; return;
} }
try {
const deleteResponse = await deleteSession({ const deleteResponse = await deleteSession({
serviceConfig, serviceConfig,
sessionId: sessionCookie.id, sessionId: sessionCookie.id,
sessionToken: sessionCookie.token, sessionToken: sessionCookie.token,
}); });
const securitySettings = await getSecuritySettings({ serviceConfig });
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
if (!deleteResponse) { if (!deleteResponse) {
throw new Error("Could not delete session"); throw new Error("Could not delete session");
} }
} catch (error) {
// The backend verifies the cookie's token before it looks at the session
// state, so PermissionDenied is what a session that no longer exists answers
// (nothing to verify against). It is also what a live session with a stale
// cookie token would answer; we deliberately prune the cookie entry in that
// case too: the user explicitly asked to remove the account from this
// browser, and without its token the server-side session cannot be used by
// anyone and simply expires. Every other failure keeps the entry and is
// reported so the caller can show it.
// (A session terminated by an RP-initiated logout keeps its token id, so
// deleting it again succeeds and never reaches this branch.)
if (!isClassifiedError(error) || error.code !== Code.PermissionDenied) {
logger.error("clearSession: could not delete session", { sessionId: sessionCookie.id, error });
const t = await getTranslations("error");
return { error: t("couldNotClearSession") };
}
return removeSessionFromCookie({ session: sessionCookie, iFrameEnabled }); logger.warn("clearSession: session rejected the cookie token (gone or stale), pruning cookie entry", {
sessionId: sessionCookie.id,
});
}
// Only needed for the cookie rewrite, so it must not gate the delete above.
const securitySettings = await getSecuritySettings({ serviceConfig });
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
await removeSessionFromCookie({ session: sessionCookie, iFrameEnabled });
} }