diff --git a/.gitignore b/.gitignore index 75f6f2799..79cd0fcc8 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,12 @@ go.work.sum CLAUDE.md .mcp.json .gemini/* +.claude/ +.cursor/ +.codex/ +.windsurf/ +.copilot/ +.aider* docs_old apps/docs/package-lock.json diff --git a/apps/login/locales/ar.json b/apps/login/locales/ar.json index 90a98bcc5..80775be85 100644 --- a/apps/login/locales/ar.json +++ b/apps/login/locales/ar.json @@ -457,6 +457,7 @@ "sessionExpired": "انتهت صلاحية جلستك الحالية. يرجى تسجيل الدخول مرة أخرى.", "failedLoading": "فشل تحميل البيانات. يرجى المحاولة مرة أخرى.", "tryagain": "حاول مرة أخرى", - "couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة" + "couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة", + "couldNotClearSession": "تعذر إنهاء الجلسة. يرجى المحاولة مرة أخرى." } } diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 88f02d69e..d106f768f 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -457,6 +457,7 @@ "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", "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." } } diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 5bd13795c..512e160f8 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -457,6 +457,7 @@ "sessionExpired": "Your current session has expired. Please login again.", "failedLoading": "Failed to load data. Please 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." } } diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 0d93bb10b..0b3f7c932 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -457,6 +457,7 @@ "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.", "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." } } diff --git a/apps/login/locales/fr.json b/apps/login/locales/fr.json index 0fdd73b69..7ff211c51 100644 --- a/apps/login/locales/fr.json +++ b/apps/login/locales/fr.json @@ -457,6 +457,7 @@ "sessionExpired": "Votre session actuelle a expiré. Veuillez vous reconnecter.", "failedLoading": "Échec du chargement des données. Veuillez 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." } } diff --git a/apps/login/locales/hu.json b/apps/login/locales/hu.json index 26e9067ad..2e5d77a9f 100644 --- a/apps/login/locales/hu.json +++ b/apps/login/locales/hu.json @@ -457,6 +457,7 @@ "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.", "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." } } diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index f25ae68d5..e8d973031 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -457,6 +457,7 @@ "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", "failedLoading": "Impossibile caricare i dati. 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." } } diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 8c8da9bbc..caaa4124c 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -450,7 +450,8 @@ "sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。", "failedLoading": "データの読み込みに失敗しました。再試行してください。", "tryagain": "再試行", - "couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています" + "couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています", + "couldNotClearSession": "セッションを終了できませんでした。もう一度お試しください。" }, "zitadel": { "errors": { diff --git a/apps/login/locales/nl.json b/apps/login/locales/nl.json index f1cd4a0c2..2e0f4fc32 100644 --- a/apps/login/locales/nl.json +++ b/apps/login/locales/nl.json @@ -457,6 +457,7 @@ "sessionExpired": "Uw huidige sessie is verlopen. Log opnieuw in.", "failedLoading": "Gegevens laden mislukt. Probeer het opnieuw.", "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." } } diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index c269cd13e..059c95f14 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -457,6 +457,7 @@ "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", "failedLoading": "Nie udało się załadować danych. 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." } } diff --git a/apps/login/locales/pt.json b/apps/login/locales/pt.json index 3ab36cd73..9f8446fea 100644 --- a/apps/login/locales/pt.json +++ b/apps/login/locales/pt.json @@ -457,6 +457,7 @@ "sessionExpired": "Sua sessão atual expirou. Por favor, faça login novamente.", "failedLoading": "Falha ao carregar dados. Por favor, tente 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." } } diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index b0e2b3424..7cf13a994 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -457,6 +457,7 @@ "sessionExpired": "Ваша сессия истекла. Войдите снова.", "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", "tryagain": "Попробовать снова", - "couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе" + "couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе", + "couldNotClearSession": "Не удалось завершить сеанс. Пожалуйста, попробуйте ещё раз." } } diff --git a/apps/login/locales/tr.json b/apps/login/locales/tr.json index 591053ae0..3627841de 100644 --- a/apps/login/locales/tr.json +++ b/apps/login/locales/tr.json @@ -457,6 +457,7 @@ "sessionExpired": "Mevcut oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.", "failedLoading": "Veri yüklenemedi. Lütfen tekrar deneyin.", "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." } } diff --git a/apps/login/locales/uk.json b/apps/login/locales/uk.json index 35d35820b..0c39e35b5 100644 --- a/apps/login/locales/uk.json +++ b/apps/login/locales/uk.json @@ -457,6 +457,7 @@ "sessionExpired": "Ваш поточний сеанс закінчився. Будь ласка, увійдіть знову.", "failedLoading": "Не вдалося завантажити дані. Будь ласка, спробуйте ще раз.", "tryagain": "Спробувати ще раз", - "couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача" + "couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача", + "couldNotClearSession": "Не вдалося завершити сеанс. Будь ласка, спробуйте ще раз." } } diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 564e9d146..03b674631 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -457,6 +457,7 @@ "sessionExpired": "当前会话已过期,请重新登录。", "failedLoading": "加载数据失败,请再试一次。", "tryagain": "重试", - "couldNotContinueSession": "无法继续会话 - 缺少用户信息" + "couldNotContinueSession": "无法继续会话 - 缺少用户信息", + "couldNotClearSession": "无法结束会话。请重试。" } } diff --git a/apps/login/src/app/(login)/accounts/page.tsx b/apps/login/src/app/(login)/accounts/page.tsx index 475e8e5bd..d429e91da 100644 --- a/apps/login/src/app/(login)/accounts/page.tsx +++ b/apps/login/src/app/(login)/accounts/page.tsx @@ -1,11 +1,13 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SessionsList } from "@/components/sessions-list"; import { Translated } from "@/components/translated"; -import { getAllSessionCookieIds } from "@/lib/cookies"; +import { getAllSessions } from "@/lib/cookies"; import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg, listSessions, ServiceConfig } from "@/lib/zitadel"; import { UserPlusIcon } from "@heroicons/react/24/outline"; +import { create } from "@zitadel/client"; 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 { getTranslations } from "next-intl/server"; // import { getLocale } from "next-intl/server"; @@ -18,24 +20,52 @@ export async function generateMetadata(): Promise { } 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) { - const response = await listSessions({ - serviceConfig, - ids: cookieIds.filter((id) => !!id) as string[], - }); - - let sessions = response?.sessions ?? []; - if (organization) { - sessions = sessions.filter((s) => s.factors?.user?.organizationId === organization); - } - - return sessions; - } else { + if (!sessionCookies || !sessionCookies.length) { console.info("No session cookie found."); return []; } + + // 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) { + sessions = sessions.filter((s) => s.factors?.user?.organizationId === organization); + } + + return sessions; } export default async function Page(props: { searchParams: Promise> }) { diff --git a/apps/login/src/components/session-clear-item.tsx b/apps/login/src/components/session-clear-item.tsx index f43e2a574..b832e3d8f 100644 --- a/apps/login/src/components/session-clear-item.tsx +++ b/apps/login/src/components/session-clear-item.tsx @@ -4,9 +4,10 @@ import { clearSession } from "@/lib/server/session"; import { timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import moment from "moment"; -import { useLocale } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { Alert } from "./alert"; import { Avatar } from "./avatar"; import { isSessionPrimaryFactorAndLifetimeValid } from "./session-item"; import { Translated } from "./translated"; @@ -14,83 +15,93 @@ import { Translated } from "./translated"; export function SessionClearItem({ session, reload }: { session: Session; reload: () => void }) { const currentLocale = useLocale(); moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); + const t = useTranslations("error"); const [_loading, setLoading] = useState(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 { setLoading(true); - const response = await clearSession({ - sessionId: id, - }) - .catch((error) => { - setError(error.message); - return; - }) - .finally(() => { - setLoading(false); - }); - - return response; + setError(null); + try { + const response = await clearSession({ sessionId: id }); + if (response && "error" in response && response.error) { + setError(response.error); + return false; + } + return true; + } catch { + setError(t("couldNotClearSession")); + return false; + } finally { + setLoading(false); + } } const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session); - const [_error, setError] = useState(null); + const [error, setError] = useState(null); // TODO: To we have to call this? useRouter(); return ( - +
+ {session.factors?.user?.displayName} + {session.factors?.user?.loginName} + {valid ? ( + + {verifiedAt && ( + + )} + + ) : ( + verifiedAt && ( + + expired {session.expirationDate && moment(timestampDate(session.expirationDate)).fromNow()} + + ) + )} +
+ + +
+
+ +
+ + {valid ? ( +
+ ) : ( +
+ )} +
+ + {error && {error}} + ); } diff --git a/apps/login/src/components/session-item.tsx b/apps/login/src/components/session-item.tsx index f6e85f98f..a0d5c7c9c 100644 --- a/apps/login/src/components/session-item.tsx +++ b/apps/login/src/components/session-item.tsx @@ -8,9 +8,10 @@ import * as Tooltip from "@radix-ui/react-tooltip"; import { Timestamp, timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import moment from "moment"; -import { useLocale } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; +import { Alert } from "./alert"; import { AutoSubmitForm } from "./auto-submit-form"; import { Avatar } from "./avatar"; import { Translated } from "./translated"; @@ -34,123 +35,134 @@ export function isSessionPrimaryFactorAndLifetimeValid(session: Partial export function SessionItem({ session, reload, requestId }: { session: Session; reload: () => void; requestId?: string }) { const currentLocale = useLocale(); moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); + const t = useTranslations("error"); const [_loading, setLoading] = useState(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 { setLoading(true); - const response = await clearSession({ - sessionId: id, - }) - .catch((error) => { - setError(error.message); - return; - }) - .finally(() => { - setLoading(false); - }); - - return response; + setError(null); + try { + const response = await clearSession({ sessionId: id }); + if (response && "error" in response && response.error) { + setError(response.error); + return false; + } + return true; + } catch { + setError(t("couldNotClearSession")); + return false; + } finally { + setLoading(false); + } } const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session); const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); - const [_error, setError] = useState(null); + const [error, setError] = useState(null); const router = useRouter(); return ( - - {samlData && } - - - - {valid && session.expirationDate && ( - - - Expires {moment(timestampDate(session.expirationDate)).fromNow()} - - - - )} - +
+ +
+ +
+ {session.factors?.user?.displayName} + {session.factors?.user?.loginName} + {valid ? ( + + {" "} + {verifiedAt && moment(timestampDate(verifiedAt)).fromNow()} + + ) : ( + verifiedAt && ( + + {" "} + {session.expirationDate && moment(timestampDate(session.expirationDate)).fromNow()} + + ) + )} +
+ + +
+ {valid ? ( +
+ ) : ( +
+ )} + + { + event.preventDefault(); + event.stopPropagation(); + if (await clearSessionId(session.id)) { + reload(); + } + }} + /> +
+ + + {valid && session.expirationDate && ( + + + Expires {moment(timestampDate(session.expirationDate)).fromNow()} + + + + )} + + {error && {error}} + ); } diff --git a/apps/login/src/components/sessions-clear-list.tsx b/apps/login/src/components/sessions-clear-list.tsx index 1c9e85f33..29c326351 100644 --- a/apps/login/src/components/sessions-clear-list.tsx +++ b/apps/login/src/components/sessions-clear-list.tsx @@ -3,6 +3,7 @@ import { clearSession } from "@/lib/server/session"; import { timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { useTranslations } from "next-intl"; import { redirect, useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { Alert, AlertType } from "./alert"; @@ -18,7 +19,9 @@ type Props = { export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, organization }: Props) { const [list, setList] = useState(sessions); + const [error, setError] = useState(null); const router = useRouter(); + const t = useTranslations("error"); const clearHintedSession = useCallback(async () => { console.log("Clearing session for login hint:", logoutHint); @@ -28,15 +31,22 @@ export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, })?.id; if (sessionIdToBeCleared) { - const clearSessionResponse = await clearSession({ - sessionId: sessionIdToBeCleared, - }).catch((error) => { + let clearError: string | undefined; + try { + const clearSessionResponse = await clearSession({ sessionId: sessionIdToBeCleared }); + if (clearSessionResponse && "error" in clearSessionResponse) { + clearError = clearSessionResponse.error; + } + } catch (error) { console.error("Error clearing session:", error); - return; - }); + clearError = t("couldNotClearSession"); + } - if (!clearSessionResponse) { - console.error("Failed to clear session for login hint:", logoutHint); + // Do not tell the RP the logout completed when the session was kept: + // show the error and leave the card so the user can retry. + if (clearError) { + setError(clearError); + return; } if (postLogoutRedirectUri) { @@ -53,7 +63,7 @@ export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, } else { console.warn(`No session found for login hint: ${logoutHint}`); } - }, [logoutHint, sessions, postLogoutRedirectUri, organization, router]); + }, [logoutHint, sessions, postLogoutRedirectUri, organization, router, t]); useEffect(() => { if (logoutHint) { @@ -86,6 +96,7 @@ export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, /> ); })} + {error && {error}} {list.length === 0 && ( diff --git a/apps/login/src/lib/cookies.ts b/apps/login/src/lib/cookies.ts index 3e97b5690..014f6ba7d 100644 --- a/apps/login/src/lib/cookies.ts +++ b/apps/login/src/lib/cookies.ts @@ -231,34 +231,17 @@ export async function getSessionCookieByLoginName({ /** * - * @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 */ export async function getAllSessionCookieIds(cleanup: boolean = false): Promise { - const cookiesList = await cookies(); - const stringifiedCookie = cookiesList.get("sessions"); - - if (!stringifiedCookie?.value) { - return []; - } - - const sessions: SessionCookie[] = 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); + const sessions = await getAllSessions(cleanup); + return sessions.map(({ id }) => 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 */ export async function getAllSessions(cleanup: boolean = false): Promise[]> { diff --git a/apps/login/src/lib/server/flow-initiation.test.ts b/apps/login/src/lib/server/flow-initiation.test.ts index 5c90e7735..c4eea5a05 100644 --- a/apps/login/src/lib/server/flow-initiation.test.ts +++ b/apps/login/src/lib/server/flow-initiation.test.ts @@ -1,3 +1,4 @@ +import type { Cookie } from "@/lib/cookies"; import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 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); } +function makeCookie(id: string, overrides?: Partial): Cookie { + return { id, token: "tok", loginName: "", creationTs: "", expirationTs: "", changeTs: "", ...overrides }; +} + function makeBaseParams(overrides?: Partial): FlowInitiationParams { return { serviceConfig: { baseUrl: "https://api.example.com" } as any, @@ -243,7 +248,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { const res = await handleOIDCFlowInitiation( makeBaseParams({ 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( makeBaseParams({ 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( makeBaseParams({ 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( makeBaseParams({ 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( makeBaseParams({ 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( makeBaseParams({ 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( makeBaseParams({ 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( makeBaseParams({ 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; + let mockConstructUrl: ReturnType; + let mockFindValidSession: ReturnType; + let mockSendLoginname: ReturnType; + + // 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: "user@org.com", organization: "111111" }); + const staleOtherOrgCookie = makeCookie("session-stale-other", { loginName: "user@other.com", organization: "999999" }); + const otherOrgSession = { + id: "session-other", + factors: { user: { id: "user2", organizationId: "999999", loginName: "user@other.com" } }, + }; + + function authRequestWith(overrides: Record) { + 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: "hinted@org.com" }); + 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: "hinted@org.com", + }); + + const res = await handleOIDCFlowInitiation( + makeBaseParams({ + sessions: [otherOrgSession] as any, + sessionCookies: [ + makeCookie("session-other", { loginName: "user@other.com", 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", () => { let mockGetAuthRequest: ReturnType; let mockConstructUrl: ReturnType; @@ -700,7 +890,7 @@ describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix makeBaseParams({ requestId: "oidc_abc123", 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({ requestId: "oidc_abc123", sessions: [existingSession] as any, - sessionCookies: [{ id: "session-1", token: "tok" }], + sessionCookies: [makeCookie("session-1")], }), ); diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 7a4d5c058..e0c758045 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -1,6 +1,6 @@ import { getValidLocaleFromUILocales } from "@/lib/auth-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 { idpTypeToSlug } from "@/lib/idp"; @@ -222,11 +222,22 @@ function getEligibleSessions(sessions: Session[], organization?: string): Sessio 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 { serviceConfig: ServiceConfig; requestId: string; sessions: Session[]; - sessionCookies: any[]; + sessionCookies: Cookie[]; request: NextRequest; } @@ -366,13 +377,19 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { if (eligibleSessions.length === 0) { - return gotoLoginname({ - request, - requestId, - loginHint: authRequest.loginHint, - organization, - orgDomain, - }); + // 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({ + request, + requestId, + loginHint: authRequest.loginHint, + organization, + orgDomain, + }); + } } return gotoAccounts({ request, @@ -464,7 +481,9 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr // Prefill loginname (not the account picker) for an unresolved hint or when // 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({ request, requestId, @@ -534,6 +553,26 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } } } 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. const hintResponse = await resolveLoginHint({ request, diff --git a/apps/login/src/lib/server/session.test.ts b/apps/login/src/lib/server/session.test.ts new file mode 100644 index 000000000..49b7894b7 --- /dev/null +++ b/apps/login/src/lib/server/session.test.ts @@ -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: "user@example.com" }; + +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(); + }); +}); diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 7a33481ce..59261f65f 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -11,7 +11,7 @@ import { listAuthenticationMethodTypes, listUsers, } 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 { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; @@ -294,7 +294,7 @@ type ClearSessionOptions = { sessionId: string; }; -export async function clearSession(options: ClearSessionOptions) { +export async function clearSession(options: ClearSessionOptions): Promise<{ error?: string } | void> { const _headers = await headers(); const { serviceConfig } = getServiceConfig(_headers); @@ -306,18 +306,40 @@ export async function clearSession(options: ClearSessionOptions) { return; } - const deleteResponse = await deleteSession({ - serviceConfig, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + try { + const deleteResponse = await deleteSession({ + serviceConfig, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + if (!deleteResponse) { + 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") }; + } + 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; - if (!deleteResponse) { - throw new Error("Could not delete session"); - } - - return removeSessionFromCookie({ session: sessionCookie, iFrameEnabled }); + await removeSessionFromCookie({ session: sessionCookie, iFrameEnabled }); }