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