mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -457,6 +457,7 @@
|
||||
"sessionExpired": "انتهت صلاحية جلستك الحالية. يرجى تسجيل الدخول مرة أخرى.",
|
||||
"failedLoading": "فشل تحميل البيانات. يرجى المحاولة مرة أخرى.",
|
||||
"tryagain": "حاول مرة أخرى",
|
||||
"couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة"
|
||||
"couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة",
|
||||
"couldNotClearSession": "تعذر إنهاء الجلسة. يرجى المحاولة مرة أخرى."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +450,8 @@
|
||||
"sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。",
|
||||
"failedLoading": "データの読み込みに失敗しました。再試行してください。",
|
||||
"tryagain": "再試行",
|
||||
"couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています"
|
||||
"couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています",
|
||||
"couldNotClearSession": "セッションを終了できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"zitadel": {
|
||||
"errors": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +457,7 @@
|
||||
"sessionExpired": "Ваша сессия истекла. Войдите снова.",
|
||||
"failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.",
|
||||
"tryagain": "Попробовать снова",
|
||||
"couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе"
|
||||
"couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе",
|
||||
"couldNotClearSession": "Не удалось завершить сеанс. Пожалуйста, попробуйте ещё раз."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +457,7 @@
|
||||
"sessionExpired": "Ваш поточний сеанс закінчився. Будь ласка, увійдіть знову.",
|
||||
"failedLoading": "Не вдалося завантажити дані. Будь ласка, спробуйте ще раз.",
|
||||
"tryagain": "Спробувати ще раз",
|
||||
"couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача"
|
||||
"couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача",
|
||||
"couldNotClearSession": "Не вдалося завершити сеанс. Будь ласка, спробуйте ще раз."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +457,7 @@
|
||||
"sessionExpired": "当前会话已过期,请重新登录。",
|
||||
"failedLoading": "加载数据失败,请再试一次。",
|
||||
"tryagain": "重试",
|
||||
"couldNotContinueSession": "无法继续会话 - 缺少用户信息"
|
||||
"couldNotContinueSession": "无法继续会话 - 缺少用户信息",
|
||||
"couldNotClearSession": "无法结束会话。请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
}
|
||||
|
||||
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<Record<string | number | symbol, string | undefined>> }) {
|
||||
|
||||
@@ -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<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);
|
||||
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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// TODO: To we have to call this?
|
||||
useRouter();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
clearSessionId(session.id).then(() => {
|
||||
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"
|
||||
>
|
||||
<div className="pr-4">
|
||||
<Avatar
|
||||
size="small"
|
||||
loginName={session.factors?.user?.loginName as string}
|
||||
name={session.factors?.user?.displayName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-xs text-ellipsis opacity-80">{session.factors?.user?.loginName}</span>
|
||||
{valid ? (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
{verifiedAt && (
|
||||
<Translated
|
||||
i18nKey="verifiedAt"
|
||||
namespace="logout"
|
||||
data={{ time: moment(timestampDate(verifiedAt)).fromNow() }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
expired {session.expirationDate && moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<div className="relative flex flex-row items-center">
|
||||
<div className="text-warn-light-500 dark:text-warn-dark-500 mr-6 flex hidden items-center justify-center rounded-full bg-[#ff0000]/10 px-2 py-[2px] text-xs transition-all group-hover:block dark:bg-[#ff0000]/10">
|
||||
<Translated i18nKey="clear" namespace="logout" />
|
||||
<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (await clearSessionId(session.id)) {
|
||||
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"
|
||||
>
|
||||
<div className="pr-4">
|
||||
<Avatar
|
||||
size="small"
|
||||
loginName={session.factors?.user?.loginName as string}
|
||||
name={session.factors?.user?.displayName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{valid ? (
|
||||
<div className="absolute right-0 mx-2 h-2 w-2 transform rounded-full bg-green-500 transition-all"></div>
|
||||
) : (
|
||||
<div className="absolute right-0 mx-2 h-2 w-2 transform rounded-full bg-red-500 transition-all"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-xs text-ellipsis opacity-80">{session.factors?.user?.loginName}</span>
|
||||
{valid ? (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
{verifiedAt && (
|
||||
<Translated
|
||||
i18nKey="verifiedAt"
|
||||
namespace="logout"
|
||||
data={{ time: moment(timestampDate(verifiedAt)).fromNow() }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
expired {session.expirationDate && moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<div className="relative flex flex-row items-center">
|
||||
<div className="text-warn-light-500 dark:text-warn-dark-500 mr-6 flex hidden items-center justify-center rounded-full bg-[#ff0000]/10 px-2 py-[2px] text-xs transition-all group-hover:block dark:bg-[#ff0000]/10">
|
||||
<Translated i18nKey="clear" namespace="logout" />
|
||||
</div>
|
||||
|
||||
{valid ? (
|
||||
<div className="absolute right-0 mx-2 h-2 w-2 transform rounded-full bg-green-500 transition-all"></div>
|
||||
) : (
|
||||
<div className="absolute right-0 mx-2 h-2 w-2 transform rounded-full bg-red-500 transition-all"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Session>
|
||||
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<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);
|
||||
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<string, string> } | null>(null);
|
||||
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (valid && session?.factors?.user) {
|
||||
const sessionPayload: ContinueWithSessionCommand = session;
|
||||
if (requestId) {
|
||||
sessionPayload.requestId = requestId;
|
||||
<>
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (valid && session?.factors?.user) {
|
||||
const sessionPayload: ContinueWithSessionCommand = session;
|
||||
if (requestId) {
|
||||
sessionPayload.requestId = requestId;
|
||||
}
|
||||
|
||||
const callbackResponse = await continueWithSession(sessionPayload);
|
||||
|
||||
handleServerActionResponse(callbackResponse, router, setSamlData, (e) => setError(e));
|
||||
} else if (session.factors?.user) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await sendLoginname({
|
||||
loginName: session.factors?.user?.loginName,
|
||||
organization: session.factors.user.organizationId,
|
||||
requestId: requestId,
|
||||
});
|
||||
|
||||
handleServerActionResponse(res, router, setSamlData, (e) => setError(e));
|
||||
} catch {
|
||||
setError("An internal error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const callbackResponse = await continueWithSession(sessionPayload);
|
||||
|
||||
handleServerActionResponse(callbackResponse, router, setSamlData, (e) => setError(e));
|
||||
} else if (session.factors?.user) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await sendLoginname({
|
||||
loginName: session.factors?.user?.loginName,
|
||||
organization: session.factors.user.organizationId,
|
||||
requestId: requestId,
|
||||
});
|
||||
|
||||
handleServerActionResponse(res, router, setSamlData, (e) => setError(e));
|
||||
} catch {
|
||||
setError("An internal error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<div className="pr-4">
|
||||
<Avatar
|
||||
size="small"
|
||||
loginName={session.factors?.user?.loginName as string}
|
||||
name={session.factors?.user?.displayName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-xs text-ellipsis opacity-80">{session.factors?.user?.loginName}</span>
|
||||
{valid ? (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
<Translated i18nKey="verified" namespace="accounts" />{" "}
|
||||
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
<Translated i18nKey="expired" namespace="accounts" />{" "}
|
||||
{session.expirationDate && moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<div className="relative flex flex-row items-center">
|
||||
{valid ? (
|
||||
<div className="absolute right-6 mx-2 h-2 w-2 transform rounded-full bg-green-500 transition-all group-hover:right-6 sm:right-0"></div>
|
||||
) : (
|
||||
<div className="absolute right-6 mx-2 h-2 w-2 transform rounded-full bg-red-500 transition-all group-hover:right-6 sm:right-0"></div>
|
||||
)}
|
||||
|
||||
<XCircleIcon
|
||||
className="h-5 w-5 opacity-50 transition-all group-hover:block hover:opacity-100 sm:hidden"
|
||||
onClick={async (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await clearSessionId(session.id);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{valid && session.expirationDate && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="bg-background-light-500 dark:bg-background-dark-500 z-50 rounded-md border px-3 py-2 text-xs text-black shadow-xl select-none dark:border-white/20 dark:text-white"
|
||||
sideOffset={5}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
Expires {moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
<Tooltip.Arrow className="fill-white dark:fill-white/20" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<div className="pr-4">
|
||||
<Avatar
|
||||
size="small"
|
||||
loginName={session.factors?.user?.loginName as string}
|
||||
name={session.factors?.user?.displayName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-xs text-ellipsis opacity-80">{session.factors?.user?.loginName}</span>
|
||||
{valid ? (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
<Translated i18nKey="verified" namespace="accounts" />{" "}
|
||||
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-xs text-ellipsis opacity-80">
|
||||
<Translated i18nKey="expired" namespace="accounts" />{" "}
|
||||
{session.expirationDate && moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<div className="relative flex flex-row items-center">
|
||||
{valid ? (
|
||||
<div className="absolute right-6 mx-2 h-2 w-2 transform rounded-full bg-green-500 transition-all group-hover:right-6 sm:right-0"></div>
|
||||
) : (
|
||||
<div className="absolute right-6 mx-2 h-2 w-2 transform rounded-full bg-red-500 transition-all group-hover:right-6 sm:right-0"></div>
|
||||
)}
|
||||
|
||||
<XCircleIcon
|
||||
className="h-5 w-5 opacity-50 transition-all group-hover:block hover:opacity-100 sm:hidden"
|
||||
onClick={async (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (await clearSessionId(session.id)) {
|
||||
reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{valid && session.expirationDate && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="bg-background-light-500 dark:bg-background-dark-500 z-50 rounded-md border px-3 py-2 text-xs text-black shadow-xl select-none dark:border-white/20 dark:text-white"
|
||||
sideOffset={5}
|
||||
>
|
||||
Expires {moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
<Tooltip.Arrow className="fill-white dark:fill-white/20" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Session[]>(sessions);
|
||||
const [error, setError] = useState<string | null>(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 && <Alert>{error}</Alert>}
|
||||
{list.length === 0 && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="noResults" namespace="logout" />
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
export async function getAllSessionCookieIds<T>(cleanup: boolean = false): Promise<string[]> {
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
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);
|
||||
const sessions = await getAllSessions<T>(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<T>(cleanup: boolean = false): Promise<SessionCookie<T>[]> {
|
||||
|
||||
@@ -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>): Cookie {
|
||||
return { id, token: "tok", loginName: "", creationTs: "", expirationTs: "", changeTs: "", ...overrides };
|
||||
}
|
||||
|
||||
function makeBaseParams(overrides?: Partial<FlowInitiationParams>): 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<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", () => {
|
||||
let mockGetAuthRequest: ReturnType<typeof vi.fn>;
|
||||
let mockConstructUrl: ReturnType<typeof vi.fn>;
|
||||
@@ -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")],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user