diff --git a/apps/docs/content/self-hosting/deploy/compose.mdx b/apps/docs/content/self-hosting/deploy/compose.mdx index 5658697466..69b5e9922b 100644 --- a/apps/docs/content/self-hosting/deploy/compose.mdx +++ b/apps/docs/content/self-hosting/deploy/compose.mdx @@ -88,12 +88,15 @@ The masterkey [encrypts sensitive data at rest](/concepts/architecture/secrets). ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 > .env +# Generate the Login UI session cookie secret (at least 32 characters) +echo "LOGIN_SESSION_COOKIE_SECRET=$(openssl rand -base64 32)" >> .env + # Set strong database passwords echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 > .env echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 > .env ``` -The commands above cover the masterkey and the database passwords. The **initial admin user** (`zitadel-admin`) has a password too, defaulting to `Password1!`. To set your own before the first start, add it to `.env`: +The commands above cover the masterkey, the session cookie secret and the database passwords. The **initial admin user** (`zitadel-admin`) has a password too, defaulting to `Password1!`. To set your own before the first start, add it to `.env`: ```dotenv ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=MyInitialPassw0rd! diff --git a/apps/docs/content/self-hosting/manage/login-client.mdx b/apps/docs/content/self-hosting/manage/login-client.mdx index e5aa42d9ad..bcd02bdf79 100644 --- a/apps/docs/content/self-hosting/manage/login-client.mdx +++ b/apps/docs/content/self-hosting/manage/login-client.mdx @@ -34,6 +34,32 @@ For example: docker run -p 3000:3000 -v /path/on/your/host/login-client.pat:/path/in/container/login-client.pat:ro -e ZITADEL_SERVICE_USER_TOKEN_FILE=/path/in/container/login-client.pat ghcr.io/zitadel/zitadel-login:latest ``` +## Session cookie signing + +The Login UI stores the sessions of a browser in an `httpOnly` cookie and signs every entry, so that a tampered or guessed session ID is ignored. +If no dedicated secret is configured, the signing key is derived from the API credential you configured above (`ZITADEL_SERVICE_USER_TOKEN`, `SYSTEM_USER_PRIVATE_KEY`, `SYSTEM_USER_PRIVATE_KEY_FILE` or `ZITADEL_LOGINCLIENT_KEYFILE`). **This fallback is deprecated and will be removed in a future major release; set a dedicated secret as described below.** +The Login UI logs a warning at startup while it uses this fallback. + +The fallback has one consequence to be aware of: **rotating the value of that credential (for example creating a new PAT) invalidates all existing session cookies**, and users have to sign in to the Login UI again. +Their application sessions and tokens are not affected. +Switching from one credential type to another (for example from a PAT to a login client key) does not invalidate cookies as long as both credentials are configured during the migration. + +Set a dedicated secret with `ZITADEL_SESSION_COOKIE_SECRET` (at least 32 characters, for example `openssl rand -base64 32`). This is the recommended configuration and will become required in a future major release: it decouples the cookie lifetime from credential rotation. +If a configured value is shorter than 32 characters, the Login UI does not fall back to the API credential: it logs an error and reports not ready on `/ready`. +It accepts a comma-separated list, which allows rotating the secret without signing users out: the first value is used for signing, all values are accepted for verification. + +```bash +# initial setup +ZITADEL_SESSION_COOKIE_SECRET=$(openssl rand -base64 32) + +# rotate: sign with the new secret, still accept cookies signed with the old one +ZITADEL_SESSION_COOKIE_SECRET=, +# after the longest session lifetime has passed, drop the old secret +ZITADEL_SESSION_COOKIE_SECRET= +``` + +All replicas of the Login UI must be configured with the same secret (or the same credential), otherwise cookies written by one replica are rejected by the others. + ## Enable the Login UI for all users diff --git a/apps/login/locales/ar.json b/apps/login/locales/ar.json index c9d4bf2c36..b3db3caf69 100644 --- a/apps/login/locales/ar.json +++ b/apps/login/locales/ar.json @@ -467,7 +467,7 @@ "noUserCode": "لم يتم تقديم رمز مستخدم!", "noDeviceRequest": "لم يتم العثور على طلب جهاز.", "unknownContext": "تعذر الحصول على سياق المستخدم. تأكد من إدخال اسم المستخدم أولاً أو تقديم loginName كمعلمة بحث.", - "sessionExpired": "انتهت صلاحية جلستك الحالية. يرجى تسجيل الدخول مرة أخرى.", + "sessionExpired": "انتهت صلاحية جلستك أو تتطلب إعادة المصادقة. يرجى تسجيل الدخول مرة أخرى.", "failedLoading": "فشل تحميل البيانات. يرجى المحاولة مرة أخرى.", "tryagain": "حاول مرة أخرى", "couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة", diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 9c3f23f155..d5c396cf9f 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -467,7 +467,7 @@ "noUserCode": "Kein Benutzercode angegeben!", "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", - "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "sessionExpired": "Ihre Sitzung ist abgelaufen oder erfordert eine erneute Authentifizierung. 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", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 22452f6bd7..69e294d7e1 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -467,7 +467,7 @@ "noUserCode": "No user code provided!", "noDeviceRequest": "No device request found.", "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", - "sessionExpired": "Your current session has expired. Please login again.", + "sessionExpired": "Your session has expired or requires re-authentication. Please sign in again.", "failedLoading": "Failed to load data. Please try again.", "tryagain": "Try Again", "couldNotContinueSession": "Could not continue with session - missing user information", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index efa02b806a..99f656e98e 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -467,7 +467,7 @@ "noUserCode": "¡No se proporcionó código de usuario!", "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", - "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "sessionExpired": "Tu sesión ha expirado o requiere una nueva autenticación. 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", diff --git a/apps/login/locales/fr.json b/apps/login/locales/fr.json index 1b34302e11..9d0da43e49 100644 --- a/apps/login/locales/fr.json +++ b/apps/login/locales/fr.json @@ -467,7 +467,7 @@ "noUserCode": "Aucun code utilisateur fourni !", "noDeviceRequest": "Aucune demande d'appareil trouvée.", "unknownContext": "Impossible d'obtenir le contexte de l'utilisateur. Assurez-vous d'abord de saisir le nom d'utilisateur ou de fournir un loginName comme paramètre de recherche.", - "sessionExpired": "Votre session actuelle a expiré. Veuillez vous reconnecter.", + "sessionExpired": "Votre session a expiré ou nécessite une nouvelle authentification. 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", diff --git a/apps/login/locales/hu.json b/apps/login/locales/hu.json index 0d1af266bb..7d8f03cec2 100644 --- a/apps/login/locales/hu.json +++ b/apps/login/locales/hu.json @@ -467,7 +467,7 @@ "noUserCode": "Nincs megadva felhasználói kód!", "noDeviceRequest": "Nem található eszközkérés.", "unknownContext": "Nem sikerült meghatározni a felhasználó kontextusát. Győződj meg róla, hogy előbb megadtad a felhasználónevet, vagy add meg a bejelentkezési nevet keresési paraméterként.", - "sessionExpired": "A munkameneted lejárt. Kérjük, jelentkezz be újra.", + "sessionExpired": "A munkameneted lejárt, vagy újbóli hitelesítést igényel. 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", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 8012bad250..d3cbaed080 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -467,7 +467,7 @@ "noUserCode": "Nessun codice utente fornito!", "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", - "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "sessionExpired": "La tua sessione è scaduta o richiede una nuova autenticazione. Effettua nuovamente l'accesso.", "failedLoading": "Impossibile caricare i dati. Riprova.", "tryagain": "Riprova", "couldNotContinueSession": "Impossibile continuare con la sessione - informazioni utente mancanti", diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 33a6d0629c..7c8f8bffcd 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -460,7 +460,7 @@ "noUserCode": "ユーザーコードが指定されていません!", "noDeviceRequest": "デバイスリクエストが見つかりません。", "unknownContext": "ユーザーのコンテキストを取得できませんでした。最初にユーザー名を入力するか、検索パラメータとしてloginNameを指定してください。", - "sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。", + "sessionExpired": "セッションが期限切れか、再認証が必要です。再度ログインしてください。", "failedLoading": "データの読み込みに失敗しました。再試行してください。", "tryagain": "再試行", "couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています", diff --git a/apps/login/locales/nl.json b/apps/login/locales/nl.json index 6236c700a7..76cc779a4d 100644 --- a/apps/login/locales/nl.json +++ b/apps/login/locales/nl.json @@ -467,7 +467,7 @@ "noUserCode": "Geen gebruikerscode opgegeven!", "noDeviceRequest": "Geen apparaatverzoek gevonden.", "unknownContext": "Kon de context van de gebruiker niet ophalen. Zorg ervoor dat u eerst de gebruikersnaam invoert of een loginName als zoekparameter opgeeft.", - "sessionExpired": "Uw huidige sessie is verlopen. Log opnieuw in.", + "sessionExpired": "Uw sessie is verlopen of vereist opnieuw authenticatie. Log opnieuw in.", "failedLoading": "Gegevens laden mislukt. Probeer het opnieuw.", "tryagain": "Opnieuw proberen", "couldNotContinueSession": "Kon de sessie niet voortzetten - ontbrekende gebruikersinformatie", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 067f8fc973..dbc03e638b 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -467,7 +467,7 @@ "noUserCode": "Nie podano kodu użytkownika!", "noDeviceRequest": "Nie znaleziono żądania urządzenia.", "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", - "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "sessionExpired": "Twoja sesja wygasła lub wymaga ponownego uwierzytelnienia. 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", diff --git a/apps/login/locales/pt.json b/apps/login/locales/pt.json index 49729310a4..09775ee6f4 100644 --- a/apps/login/locales/pt.json +++ b/apps/login/locales/pt.json @@ -467,7 +467,7 @@ "noUserCode": "Nenhum código de usuário fornecido!", "noDeviceRequest": "Nenhuma solicitação de dispositivo encontrada.", "unknownContext": "Não foi possível obter o contexto do usuário. Certifique-se de inserir o nome de usuário primeiro ou forneça um loginName como parâmetro de busca.", - "sessionExpired": "Sua sessão atual expirou. Por favor, faça login novamente.", + "sessionExpired": "Sua sessão expirou ou requer uma nova autenticação. 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", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index ea46f778ec..01c3601721 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -467,7 +467,7 @@ "noUserCode": "Не указан код пользователя!", "noDeviceRequest": "Не найдена ни одна заявка на устройство.", "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", - "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "sessionExpired": "Ваша сессия истекла или требует повторной аутентификации. Войдите снова.", "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", "tryagain": "Попробовать снова", "couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе", diff --git a/apps/login/locales/tr.json b/apps/login/locales/tr.json index aa43acb757..1e869e800e 100644 --- a/apps/login/locales/tr.json +++ b/apps/login/locales/tr.json @@ -467,7 +467,7 @@ "noUserCode": "Kullanıcı kodu sağlanmadı!", "noDeviceRequest": "Cihaz isteği bulunamadı.", "unknownContext": "Kullanıcının bağlamı alınamadı. Önce kullanıcı adını girdiğinizden emin olun veya searchParam olarak bir loginName sağlayın.", - "sessionExpired": "Mevcut oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.", + "sessionExpired": "Oturumunuzun süresi doldu veya yeniden kimlik doğrulaması gerekiyor. 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", diff --git a/apps/login/locales/uk.json b/apps/login/locales/uk.json index ca62014796..909d852f6e 100644 --- a/apps/login/locales/uk.json +++ b/apps/login/locales/uk.json @@ -467,7 +467,7 @@ "noUserCode": "Код користувача не надано!", "noDeviceRequest": "Запит пристрою не знайдено.", "unknownContext": "Не вдалося отримати контекст користувача. Переконайтеся, що спочатку ввели ім'я користувача або вкажіть loginName як параметр пошуку.", - "sessionExpired": "Ваш поточний сеанс закінчився. Будь ласка, увійдіть знову.", + "sessionExpired": "Ваш сеанс закінчився або потребує повторної автентифікації. Будь ласка, увійдіть знову.", "failedLoading": "Не вдалося завантажити дані. Будь ласка, спробуйте ще раз.", "tryagain": "Спробувати ще раз", "couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 17f4fd3fd2..8a1f1534a5 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -467,7 +467,7 @@ "noUserCode": "未提供用户代码!", "noDeviceRequest": "没有找到设备请求。", "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", - "sessionExpired": "当前会话已过期,请重新登录。", + "sessionExpired": "会话已过期或需要重新验证身份,请重新登录。", "failedLoading": "加载数据失败,请再试一次。", "tryagain": "重试", "couldNotContinueSession": "无法继续会话 - 缺少用户信息", diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 83fafb3e71..4d69754372 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -24,6 +24,19 @@ declare namespace NodeJS { */ ZITADEL_SERVICE_USER_TOKEN: string; + /** + * Secret used to sign LoginV2 `sessions` cookie entries (a signing key is derived via HKDF). + * Recommended configuration; will become required in a future major release. + * Every value must be at least 32 characters long (e.g. `openssl rand -base64 32`); a shorter value + * disables signing and makes /ready fail instead of falling back to the API credential. + * Deprecated fallback if unset: the configured API credential is used (ZITADEL_SERVICE_USER_TOKEN, + * SYSTEM_USER_PRIVATE_KEY(_FILE) or ZITADEL_LOGINCLIENT_KEYFILE) and a warning is logged at startup. + * A comma-separated list is supported for rotation: the first value signs, all values verify. + * Unsigned or tampered cookie entries are ignored so a client cannot patch an arbitrary session. + * All replicas of a deployment must resolve the same secret. + */ + ZITADEL_SESSION_COOKIE_SECRET?: string; + /** * Path to a private key file for login client JWT authentication. * When set, the login service reads this key and signs JWTs with a diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index d0559b015f..ebf873eaff 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -89,7 +89,8 @@ export default async function Page(props: { searchParams: Promise - + {/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */} + ); } diff --git a/apps/login/src/app/(login)/otp/[method]/page.tsx b/apps/login/src/app/(login)/otp/[method]/page.tsx index d253aeca5f..0718de0227 100644 --- a/apps/login/src/app/(login)/otp/[method]/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/page.tsx @@ -92,7 +92,8 @@ export default async function Page(props: { {!session && (
- + {/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */} +
)} diff --git a/apps/login/src/app/(login)/otp/[method]/set/page.tsx b/apps/login/src/app/(login)/otp/[method]/set/page.tsx index 2b59cdd08a..2de2341b36 100644 --- a/apps/login/src/app/(login)/otp/[method]/set/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/set/page.tsx @@ -135,7 +135,8 @@ export default async function Page(props: { {!session && (
- + {/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */} +
)} diff --git a/apps/login/src/app/(login)/passkey/set/page.tsx b/apps/login/src/app/(login)/passkey/set/page.tsx index 0fc5e11d5d..05d02c5d9b 100644 --- a/apps/login/src/app/(login)/passkey/set/page.tsx +++ b/apps/login/src/app/(login)/passkey/set/page.tsx @@ -95,7 +95,8 @@ export default async function Page(props: { searchParams: Promise - + {/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */} + )} diff --git a/apps/login/src/app/(login)/u2f/set/page.tsx b/apps/login/src/app/(login)/u2f/set/page.tsx index abebc13cbd..468ecdd1bb 100644 --- a/apps/login/src/app/(login)/u2f/set/page.tsx +++ b/apps/login/src/app/(login)/u2f/set/page.tsx @@ -72,7 +72,8 @@ export default async function Page(props: { searchParams: Promise - + {/* a loginName was provided but the session could not be resolved (cookie missing, invalid or expired) */} + )} diff --git a/apps/login/src/app/ready/route.test.ts b/apps/login/src/app/ready/route.test.ts index 9fdc18464f..4d17cad879 100644 --- a/apps/login/src/app/ready/route.test.ts +++ b/apps/login/src/app/ready/route.test.ts @@ -18,12 +18,15 @@ function mockApiReady(status = 200) { describe("GET /ready", () => { let savedApiUrl: string | undefined; + let savedCookieSecret: string | undefined; let savedCustomHeaders: string | undefined; beforeEach(() => { savedApiUrl = process.env.ZITADEL_API_URL; + savedCookieSecret = process.env.ZITADEL_SESSION_COOKIE_SECRET; savedCustomHeaders = process.env.CUSTOM_REQUEST_HEADERS; process.env.ZITADEL_API_URL = "http://localhost:8080"; + process.env.ZITADEL_SESSION_COOKIE_SECRET = "test-session-cookie-secret-at-least-32-chars"; delete process.env.CUSTOM_REQUEST_HEADERS; }); @@ -33,11 +36,17 @@ describe("GET /ready", () => { } else { process.env.ZITADEL_API_URL = savedApiUrl; } + if (savedCookieSecret === undefined) { + delete process.env.ZITADEL_SESSION_COOKIE_SECRET; + } else { + process.env.ZITADEL_SESSION_COOKIE_SECRET = savedCookieSecret; + } if (savedCustomHeaders === undefined) { delete process.env.CUSTOM_REQUEST_HEADERS; } else { process.env.CUSTOM_REQUEST_HEADERS = savedCustomHeaders; } + vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -73,6 +82,37 @@ describe("GET /ready", () => { expect(await response.text()).toBe("Service unavailable"); }); + test("should return 503 when no session cookie signing secret is available", async () => { + for (const name of [ + "ZITADEL_SESSION_COOKIE_SECRET", + "ZITADEL_SERVICE_USER_TOKEN", + "SYSTEM_USER_PRIVATE_KEY", + "SYSTEM_USER_PRIVATE_KEY_FILE", + "ZITADEL_LOGINCLIENT_KEYFILE", + ]) { + vi.stubEnv(name, undefined); + } + const fetchMock = mockApiReady(); + + const response = await GET(); + + expect(response.status).toBe(503); + expect(await response.text()).toBe("Service unavailable"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("should return 503 when the dedicated session cookie secret is too short", async () => { + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "too-short"); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + const fetchMock = mockApiReady(); + + const response = await GET(); + + expect(response.status).toBe(503); + expect(await response.text()).toBe("Service unavailable"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("should return 503 when ZITADEL_API_URL is not set", async () => { const fetchMock = mockApiReady(); delete process.env.ZITADEL_API_URL; diff --git a/apps/login/src/app/ready/route.ts b/apps/login/src/app/ready/route.ts index 8864348acc..31f9623c56 100644 --- a/apps/login/src/app/ready/route.ts +++ b/apps/login/src/app/ready/route.ts @@ -1,5 +1,6 @@ import { applyCustomHeaders } from "@/lib/custom-headers"; import { createLogger } from "@/lib/logger"; +import { getSessionCookieSecretConfigError, hasSessionCookieSecret } from "@/lib/session-cookie-signature"; import { NextResponse } from "next/server"; const logger = createLogger("readiness"); @@ -14,6 +15,9 @@ const READINESS_TIMEOUT_MS = 5_000; * API's access-log and quota interceptors, so probe traffic is never counted * as authenticated requests of the instance. * + * It also fails if no session cookie signing secret is usable (a local check, + * no API call), since every login would otherwise fail when the cookie is written. + * * The configured API credentials are validated once at process startup * (see instrumentation.ts), not on every probe. */ @@ -23,6 +27,14 @@ export async function GET() { return unavailable(); } + // fail fast: without a signing secret every login would fail when the session cookie is written + if (!hasSessionCookieSecret()) { + logger.error( + `Readiness check failed: ${getSessionCookieSecretConfigError() ?? "no session cookie signing secret available (set ZITADEL_SESSION_COOKIE_SECRET)"}`, + ); + return unavailable(); + } + try { const headers = new Headers(); applyCustomHeaders({ diff --git a/apps/login/src/instrumentation.test.ts b/apps/login/src/instrumentation.test.ts index 61645b6475..84ba052aa8 100644 --- a/apps/login/src/instrumentation.test.ts +++ b/apps/login/src/instrumentation.test.ts @@ -11,6 +11,8 @@ vi.mock("./lib/verify-credentials", async (importOriginal) => ({ })); vi.mock("@/lib/service", () => ({ createServiceForHost: vi.fn() })); vi.mock("./instrumentation.node", () => ({ registerNode })); +const startupLogger = { warn: vi.fn(), error: vi.fn() }; +vi.mock("./lib/logger", () => ({ createLogger: () => startupLogger })); async function loadRegister() { vi.resetModules(); @@ -32,6 +34,9 @@ describe("register", () => { verifyApiCredentials.mockReset().mockResolvedValue("ok"); registerNode.mockReset().mockResolvedValue(null); exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + startupLogger.warn.mockReset(); + startupLogger.error.mockReset(); + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "test-session-cookie-secret-at-least-32-chars"); }); afterEach(() => { @@ -42,6 +47,7 @@ describe("register", () => { process.env[key] = value; } } + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -108,4 +114,42 @@ describe("register", () => { expect(registerNode).not.toHaveBeenCalled(); expect(verifyApiCredentials).toHaveBeenCalledTimes(1); }); + + describe("session cookie secret notice", () => { + test("logs nothing when a dedicated secret is configured", async () => { + const register = await loadRegister(); + + await register(); + + expect(startupLogger.warn).not.toHaveBeenCalled(); + expect(startupLogger.error).not.toHaveBeenCalled(); + }); + + test("warns about the deprecated credential fallback, also in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", undefined); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + const register = await loadRegister(); + + await register(); + + expect(startupLogger.warn).toHaveBeenCalledWith(expect.stringMatching(/fallback is deprecated/)); + }); + + test("logs an error for a too short secret before the credential check", async () => { + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "too-short"); + const calls: string[] = []; + startupLogger.error.mockImplementation(() => calls.push("notice")); + verifyApiCredentials.mockImplementation(async () => { + calls.push("credentials"); + return "ok"; + }); + const register = await loadRegister(); + + await register(); + + expect(calls).toEqual(["notice", "credentials"]); + expect(startupLogger.error).toHaveBeenCalledWith(expect.stringMatching(/at least 32 characters/)); + }); + }); }); diff --git a/apps/login/src/instrumentation.ts b/apps/login/src/instrumentation.ts index a202881b11..64ad53a152 100644 --- a/apps/login/src/instrumentation.ts +++ b/apps/login/src/instrumentation.ts @@ -37,6 +37,10 @@ export async function register(): Promise { _loggerProvider = await registerNode(); } + // Logged after the SDK is registered so the logger is instrumented, and before the + // credential check so it is visible even if that check exits the process. + await logSessionCookieSecretNotice(); + // Verify the configured API credentials once at startup (fail fast). // Connectivity problems are only logged; reachability is covered by the // readiness probe (/ready) and ZITADEL_API_AWAITINITIALCONN. @@ -56,6 +60,20 @@ function isOtelEnabled(): boolean { return process.env.OTEL_SDK_DISABLED !== "true"; } +/** + * Logs once at startup if session cookies are signed via the deprecated API credential fallback + * or cannot be signed at all (see lib/session-cookie-signature.ts). + */ +async function logSessionCookieSecretNotice(): Promise { + const { getSessionCookieSecretStartupNotice } = await import("./lib/session-cookie-signature"); + const notice = getSessionCookieSecretStartupNotice(); + if (!notice) { + return; + } + const { createLogger } = await import("./lib/logger"); + createLogger("startup")[notice.level](notice.message); +} + export function getLoggerProvider(): LoggerProvider | null { return _loggerProvider; } diff --git a/apps/login/src/lib/cookies.test.ts b/apps/login/src/lib/cookies.test.ts index 3f5537be64..cbe9f9e2a4 100644 --- a/apps/login/src/lib/cookies.test.ts +++ b/apps/login/src/lib/cookies.test.ts @@ -16,6 +16,7 @@ import { import { timestampDate, timestampFromMs } from "@zitadel/client"; import { cookies } from "next/headers"; +import { signSession, verifySession } from "./session-cookie-signature"; // Mock logger - use vi.hoisted to ensure it's defined before vi.mock runs const mockLogger = vi.hoisted(() => ({ @@ -42,8 +43,17 @@ vi.mock("@zitadel/client", () => ({ describe("cookies", () => { let mockCookies: any; + function stored(...sessions: Cookie[]) { + return { value: JSON.stringify(sessions.map((session) => signSession(session))) }; + } + + function writtenSessions(): Array { + return JSON.parse(mockCookies.set.mock.calls[0][0].value); + } + beforeEach(() => { vi.clearAllMocks(); + process.env.ZITADEL_SESSION_COOKIE_SECRET = "test-session-cookie-secret-at-least-32-chars"; mockCookies = { get: vi.fn(), set: vi.fn(), @@ -111,12 +121,10 @@ describe("cookies", () => { await addSessionToCookie({ session: mockSession }); - expect(mockCookies.set).toHaveBeenCalledWith( - expect.objectContaining({ - name: "sessions", - value: JSON.stringify([mockSession]), - }), - ); + const sessions = writtenSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject(mockSession); + expect(verifySession(sessions[0])).toBe(true); }); it("should prepend new session to existing sessions", async () => { @@ -129,18 +137,15 @@ describe("cookies", () => { changeTs: "1600000000000", }; - mockCookies.get.mockReturnValue({ - value: JSON.stringify([existingSession]), - }); + mockCookies.get.mockReturnValue(stored(existingSession)); await addSessionToCookie({ session: mockSession }); - const expectedSessions = [mockSession, existingSession]; - expect(mockCookies.set).toHaveBeenCalledWith( - expect.objectContaining({ - value: JSON.stringify(expectedSessions), - }), - ); + const sessions = writtenSessions(); + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject(mockSession); + expect(sessions[1]).toMatchObject(existingSession); + expect(sessions.every(verifySession)).toBe(true); }); it("should update existing session with same loginName", async () => { @@ -153,9 +158,7 @@ describe("cookies", () => { changeTs: "1600000000000", }; - mockCookies.get.mockReturnValue({ - value: JSON.stringify([existingSession]), - }); + mockCookies.get.mockReturnValue(stored(existingSession)); const updatedSession: Cookie = { ...mockSession, @@ -174,8 +177,8 @@ describe("cookies", () => { }); it("should handle cookie overflow by replacing oldest session", async () => { - // Create many sessions to exceed MAX_COOKIE_SIZE (2048) - const manySessions: Cookie[] = Array.from({ length: 10 }, (_, i) => ({ + // Create many sessions to exceed MAX_COOKIE_SIZE (3500) + const manySessions: Cookie[] = Array.from({ length: 16 }, (_, i) => ({ id: `session-${i}`, token: `token-${i}-very-long-token-to-increase-size-padding-padding-padding`, loginName: `user${i}@example.com`, @@ -184,20 +187,17 @@ describe("cookies", () => { changeTs: `${1600000000000 + i * 1000}`, })); - mockCookies.get.mockReturnValue({ - value: JSON.stringify(manySessions), - }); + mockCookies.get.mockReturnValue(stored(...manySessions)); await addSessionToCookie({ session: mockSession }); expect(mockLogger.warn).toHaveBeenCalledWith("WARNING COOKIE OVERFLOW"); - const setCall = mockCookies.set.mock.calls[0][0]; - const sessions = JSON.parse(setCall.value); + const sessions = writtenSessions(); // Should have new session and all but the first old session - expect(sessions[0]).toEqual(mockSession); - expect(sessions).not.toContain(manySessions[0]); + expect(sessions[0]).toMatchObject(mockSession); + expect(sessions.some((s) => s.id === manySessions[0].id)).toBe(false); }); it("should cleanup expired sessions when cleanup is true", async () => { @@ -220,9 +220,7 @@ describe("cookies", () => { changeTs: `${now}`, }; - mockCookies.get.mockReturnValue({ - value: JSON.stringify([expiredSession, validSession]), - }); + mockCookies.get.mockReturnValue(stored(expiredSession, validSession)); vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); vi.mocked(timestampFromMs).mockImplementation((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any); @@ -245,6 +243,7 @@ describe("cookies", () => { expect(mockCookies.set).toHaveBeenCalledWith( expect.objectContaining({ sameSite: "none", + secure: true, }), ); }); @@ -283,7 +282,7 @@ describe("cookies", () => { it("should update existing session by id", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([mockSession]), + ...stored(mockSession), }); const updatedSession: Cookie = { @@ -306,7 +305,7 @@ describe("cookies", () => { it("should throw error if session id not found", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([mockSession]), + ...stored(mockSession), }); await expect( @@ -329,7 +328,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([mockSession, expiredSession]), + ...stored(mockSession, expiredSession), }); vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); @@ -350,7 +349,7 @@ describe("cookies", () => { it("should respect iFrameEnabled parameter", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([mockSession]), + ...stored(mockSession), }); await updateSessionCookie({ @@ -388,7 +387,7 @@ describe("cookies", () => { it("should remove session by id", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); await removeSessionFromCookie({ session: session1 }); @@ -402,7 +401,7 @@ describe("cookies", () => { it("should handle removing non-existent session gracefully", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1]), + ...stored(session1), }); const nonExistentSession: Cookie = { @@ -431,7 +430,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2, expiredSession]), + ...stored(session1, session2, expiredSession), }); vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); @@ -448,7 +447,7 @@ describe("cookies", () => { it("should respect iFrameEnabled parameter", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); await removeSessionFromCookie({ @@ -485,7 +484,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getMostRecentSessionCookie(); @@ -511,7 +510,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session]), + ...stored(session), }); const result = await getMostRecentSessionCookie(); @@ -544,7 +543,7 @@ describe("cookies", () => { it("should find session by id", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getSessionCookieById({ sessionId: "session-1" }); @@ -556,7 +555,7 @@ describe("cookies", () => { it("should filter by organization when provided", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getSessionCookieById({ @@ -571,7 +570,7 @@ describe("cookies", () => { it("should return undefined if session not found", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1]), + ...stored(session1), }); await expect(getSessionCookieById({ sessionId: "non-existent" })).resolves.toBeUndefined(); @@ -579,7 +578,7 @@ describe("cookies", () => { it("should return undefined if organization doesn't match", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1]), + ...stored(session1), }); await expect( @@ -620,7 +619,7 @@ describe("cookies", () => { it("should find session by loginName", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getSessionCookieByLoginName({ @@ -634,7 +633,7 @@ describe("cookies", () => { it("should filter by organization when provided", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getSessionCookieByLoginName({ @@ -649,7 +648,7 @@ describe("cookies", () => { it("should return undefined if session not found", async () => { mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1]), + ...stored(session1), }); await expect(getSessionCookieByLoginName({ loginName: "nonexistent@example.com" })).resolves.toBeUndefined(); @@ -684,7 +683,7 @@ describe("cookies", () => { ]; mockCookies.get.mockReturnValue({ - value: JSON.stringify(sessions), + ...stored(...sessions), }); const result = await getAllSessionCookieIds(); @@ -714,7 +713,7 @@ describe("cookies", () => { ]; mockCookies.get.mockReturnValue({ - value: JSON.stringify(sessions), + ...stored(...sessions), }); vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); @@ -756,7 +755,7 @@ describe("cookies", () => { ]; mockCookies.get.mockReturnValue({ - value: JSON.stringify(sessions), + ...stored(...sessions), }); const result = await getAllSessions(); @@ -785,7 +784,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([validSession, expiredSession]), + ...stored(validSession, expiredSession), }); vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); @@ -828,7 +827,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getMostRecentCookieWithLoginname({ @@ -858,7 +857,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); await expect( @@ -890,7 +889,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getMostRecentCookieWithLoginname({ @@ -922,7 +921,7 @@ describe("cookies", () => { }; mockCookies.get.mockReturnValue({ - value: JSON.stringify([session1, session2]), + ...stored(session1, session2), }); const result = await getMostRecentCookieWithLoginname({}); @@ -940,4 +939,59 @@ describe("cookies", () => { ).resolves.toBeUndefined(); }); }); + + describe("session cookie HMAC", () => { + const victimSession: Cookie = { + id: "victim-session-id", + token: "victim-token", + loginName: "admin@example.com", + organization: "org-1", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + it("ignores an unsigned guessed session id", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([{ ...victimSession, token: "" }]), + }); + + await expect(getSessionCookieById({ sessionId: "victim-session-id" })).resolves.toBeUndefined(); + await expect(getAllSessionCookieIds()).resolves.toEqual([]); + }); + + it("ignores a session whose id was swapped after signing", async () => { + const signed = signSession({ ...victimSession, id: "attacker-session", token: "attacker-token" }); + mockCookies.get.mockReturnValue({ + value: JSON.stringify([{ ...signed, id: "victim-session-id", token: "" }]), + }); + + await expect(getSessionCookieById({ sessionId: "victim-session-id" })).resolves.toBeUndefined(); + }); + + it("does not update a session that is only present as a forged cookie entry", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([{ ...victimSession, token: "" }]), + }); + + await expect( + updateSessionCookie({ + id: "victim-session-id", + session: { ...victimSession, token: "rotated-token" }, + }), + ).rejects.toThrow("updateSessionCookie: session id not found"); + expect(mockCookies.set).not.toHaveBeenCalled(); + }); + + it("still writes a newly created session when no cookie exists", async () => { + mockCookies.get.mockReturnValue(undefined); + + await addSessionToCookie({ session: victimSession }); + + const sessions = writtenSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject(victimSession); + expect(verifySession(sessions[0])).toBe(true); + }); + }); }); diff --git a/apps/login/src/lib/cookies.ts b/apps/login/src/lib/cookies.ts index 014f6ba7df..92ba92553a 100644 --- a/apps/login/src/lib/cookies.ts +++ b/apps/login/src/lib/cookies.ts @@ -4,11 +4,14 @@ import { timestampDate, timestampFromMs } from "@zitadel/client"; import { cookies } from "next/headers"; import { LANGUAGE_COOKIE_NAME } from "./i18n"; import { createLogger } from "./logger"; +import { parseAndVerifySessions, signSession } from "./session-cookie-signature"; const logger = createLogger("cookies"); // TODO: improve this to handle overflow -const MAX_COOKIE_SIZE = 2048; +// Browsers cap a single cookie at ~4096 bytes (name, value and attributes). Keep a margin for +// the attributes and for the signature that is added to every entry when the cookie is written. +const MAX_COOKIE_SIZE = 3500; export type Cookie = { id: string; @@ -39,14 +42,36 @@ async function setSessionHttpOnlyCookie(sessions: SessionCookie[], iFrameE return cookiesList.set({ name: "sessions", - value: JSON.stringify(sessions), + value: JSON.stringify(sessions.map((session) => signSession(session))), httpOnly: true, path: "/", sameSite: resolvedSameSite, - secure: process.env.NODE_ENV === "production", + // "none" is only accepted by browsers together with "secure", otherwise the cookie is silently dropped + secure: process.env.NODE_ENV === "production" || resolvedSameSite === "none", }); } +function readSessionCookies(value: string | undefined): SessionCookie[] { + const sessions = parseAndVerifySessions>(value); + + if (value && sessions.length < countCookieEntries(value)) { + logger.warn( + `readSessionCookies: ignoring ${countCookieEntries(value) - sessions.length} session cookie entries with a missing or invalid signature (unsigned legacy cookie or signing secret changed)`, + ); + } + + return sessions; +} + +function countCookieEntries(value: string): number { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.length : 0; + } catch { + return 0; + } +} + export async function setLanguageCookie(language: string) { const cookiesList = await cookies(); @@ -76,7 +101,7 @@ export async function addSessionToCookie({ const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - let currentSessions: SessionCookie[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : []; + let currentSessions: SessionCookie[] = readSessionCookies(stringifiedCookie?.value); const index = currentSessions.findIndex((s) => s.loginName === session.loginName); @@ -85,7 +110,8 @@ export async function addSessionToCookie({ } else { const temp = [...currentSessions, session]; - if (JSON.stringify(temp).length >= MAX_COOKIE_SIZE) { + // measure the value as it will be written (including signatures) + if (JSON.stringify(temp.map((s) => signSession(s))).length >= MAX_COOKIE_SIZE) { logger.warn("WARNING COOKIE OVERFLOW"); // TODO: improve cookie handling // this replaces the first session (oldest) with the new one @@ -120,7 +146,7 @@ export async function updateSessionCookie({ const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - const sessions: SessionCookie[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [session]; + const sessions: SessionCookie[] = stringifiedCookie?.value ? readSessionCookies(stringifiedCookie.value) : [session]; const foundIndex = sessions.findIndex((session) => session.id === id); @@ -152,7 +178,7 @@ export async function removeSessionFromCookie({ const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - const sessions: SessionCookie[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [session]; + const sessions: SessionCookie[] = stringifiedCookie?.value ? readSessionCookies(stringifiedCookie.value) : [session]; const reducedSessions = sessions.filter((s) => s.id !== session.id); if (cleanup) { @@ -170,17 +196,14 @@ export async function getMostRecentSessionCookie(): Promise[] = JSON.parse(stringifiedCookie?.value); - - const latest = sessions.reduce((prev, current) => { - return prev.changeTs > current.changeTs ? prev : current; - }); - - return latest; - } else { + const sessions = readSessionCookies(stringifiedCookie?.value); + if (!sessions.length) { return undefined; } + + return sessions.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }); } export async function getSessionCookieById({ @@ -193,20 +216,8 @@ export async function getSessionCookieById({ const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - if (stringifiedCookie?.value) { - const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); - - const found = sessions.find((s) => - organization ? s.organization === organization && s.id === sessionId : s.id === sessionId, - ); - if (found) { - return found; - } else { - return undefined; - } - } else { - return undefined; - } + const sessions = readSessionCookies(stringifiedCookie?.value); + return sessions.find((s) => (organization ? s.organization === organization && s.id === sessionId : s.id === sessionId)); } export async function getSessionCookieByLoginName({ @@ -219,11 +230,7 @@ export async function getSessionCookieByLoginName({ const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - if (!stringifiedCookie?.value) { - return undefined; - } - - const sessions: SessionCookie[] = JSON.parse(stringifiedCookie.value); + const sessions = readSessionCookies(stringifiedCookie?.value); return sessions.find((s) => organization ? s.organization === organization && s.loginName === loginName : s.loginName === loginName, ); @@ -248,13 +255,13 @@ export async function getAllSessions(cleanup: boolean = false): Promise(stringifiedCookie?.value); + + if (!sessions.length) { logger.info("getAllSessions: No session cookie found, returning empty array"); return []; } - const sessions: SessionCookie[] = JSON.parse(stringifiedCookie.value); - if (cleanup) { const now = new Date(); return sessions.filter((session) => @@ -281,12 +288,12 @@ export async function getMostRecentCookieWithLoginname({ const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - if (!stringifiedCookie?.value) { + const sessions = readSessionCookies(stringifiedCookie?.value); + + if (!sessions.length) { return undefined; } - const sessions: SessionCookie[] = JSON.parse(stringifiedCookie.value); - let filtered = sessions; if (loginName) { diff --git a/apps/login/src/lib/session-cookie-signature.test.ts b/apps/login/src/lib/session-cookie-signature.test.ts new file mode 100644 index 0000000000..9ba2228fa8 --- /dev/null +++ b/apps/login/src/lib/session-cookie-signature.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + MIN_SESSION_COOKIE_SECRET_LENGTH, + getSessionCookieSecretConfigError, + getSessionCookieSecretStartupNotice, + getSessionCookieSecrets, + hasSessionCookieSecret, + isUsingCredentialFallback, + parseAndVerifySessions, + signSession, + stripSessionSignature, + verifySession, +} from "./session-cookie-signature"; + +const session = { + id: "session-1", + token: "token-1", + loginName: "user@example.com", + organization: "org-1", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", +}; + +const OLD_SECRET = "old-secret-".padEnd(MIN_SESSION_COOKIE_SECRET_LENGTH, "o"); +const NEW_SECRET = "new-secret-".padEnd(MIN_SESSION_COOKIE_SECRET_LENGTH, "n"); + +const SECRET_ENV_VARS = [ + "ZITADEL_SESSION_COOKIE_SECRET", + "ZITADEL_SERVICE_USER_TOKEN", + "SYSTEM_USER_PRIVATE_KEY", + "SYSTEM_USER_PRIVATE_KEY_FILE", + "ZITADEL_LOGINCLIENT_KEYFILE", +] as const; + +function unsetAllSecrets() { + for (const name of SECRET_ENV_VARS) { + vi.stubEnv(name, undefined); + } +} + +describe("session-cookie-signature", () => { + beforeEach(() => { + unsetAllSecrets(); + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "test-session-cookie-secret-at-least-32-chars"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("signs and verifies a session", () => { + const signed = signSession(session); + + expect(signed.sig).toEqual(expect.any(String)); + expect(verifySession(signed)).toBe(true); + expect(stripSessionSignature(signed)).toEqual(session); + }); + + test("rejects a missing signature", () => { + expect(verifySession(session)).toBe(false); + }); + + test("rejects entries with an empty id or token even if correctly signed", () => { + expect(() => signSession({ ...session, token: "" })).toThrow(/non-empty id and token/); + expect(() => signSession({ ...session, id: "" })).toThrow(/non-empty id and token/); + + // a signature over an empty token must never verify, regardless of how it was produced + const signed = signSession(session); + expect(verifySession({ ...signed, token: "" })).toBe(false); + }); + + test("does not confuse field boundaries in the signed payload", () => { + const signed = signSession({ ...session, id: "ab", token: "c" }); + + expect(verifySession({ ...signed, id: "a", token: "bc" })).toBe(false); + }); + + test("rejects a forged session id with an otherwise valid signature", () => { + const signed = signSession(session); + + expect(verifySession({ ...signed, id: "guessed-victim-session" })).toBe(false); + }); + + test("covers all fields of the entry, not only id and token", () => { + const signed = signSession(session); + + expect(verifySession({ ...signed, loginName: "other@example.com" })).toBe(false); + expect(verifySession({ ...signed, organization: "other-org" })).toBe(false); + expect(verifySession({ ...signed, changeTs: "9999999999999" })).toBe(false); + expect(verifySession({ ...signed, expirationTs: "9999999999999" })).toBe(false); + expect(verifySession({ ...signed, requestId: "injected-request" })).toBe(false); + }); + + test("is independent of key order and of undefined optional fields", () => { + const signed = signSession({ ...session, organization: undefined, requestId: undefined }); + + // simulate the JSON round-trip through the cookie: undefined keys disappear, key order may change + const roundTripped = JSON.parse(JSON.stringify(signed)); + const reordered = Object.fromEntries(Object.entries(roundTripped).reverse()); + + expect(roundTripped).not.toHaveProperty("organization"); + expect(verifySession(roundTripped)).toBe(true); + expect(verifySession(reordered)).toBe(true); + }); + + test("rejects a guessed id with an empty token and no signature", () => { + expect( + verifySession({ + ...session, + id: "guessed-victim-session", + token: "", + }), + ).toBe(false); + }); + + test("rejects a signature created with a different secret", () => { + const signed = signSession(session); + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "other-secret-".padEnd(MIN_SESSION_COOKIE_SECRET_LENGTH, "x")); + + expect(verifySession(signed)).toBe(false); + }); + + test("drops unsigned and tampered entries when parsing a cookie", () => { + const valid = signSession(session); + const forged = { ...session, id: "stolen-or-guessed-id", token: "", sig: valid.sig }; + + expect(parseAndVerifySessions(JSON.stringify([forged, valid, session]))).toEqual([session]); + }); + + test("returns an empty list for malformed cookie values", () => { + expect(parseAndVerifySessions("not-json")).toEqual([]); + expect(parseAndVerifySessions(JSON.stringify({ id: "session-1" }))).toEqual([]); + expect(parseAndVerifySessions(undefined)).toEqual([]); + }); + + test("supports secret rotation: signs with the first secret, verifies with all", () => { + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", OLD_SECRET); + const signedWithOld = signSession(session); + + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", `${NEW_SECRET}, ${OLD_SECRET}`); + expect(verifySession(signedWithOld)).toBe(true); + const signedWithNew = signSession(session); + expect(signedWithNew.sig).not.toEqual(signedWithOld.sig); + + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", NEW_SECRET); + expect(verifySession(signedWithNew)).toBe(true); + expect(verifySession(signedWithOld)).toBe(false); + }); + + test("falls back to ZITADEL_SERVICE_USER_TOKEN when no dedicated secret is set", () => { + unsetAllSecrets(); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + + expect(hasSessionCookieSecret()).toBe(true); + expect(verifySession(signSession(session))).toBe(true); + }); + + test("falls back to SYSTEM_USER_PRIVATE_KEY when no other secret is set", () => { + unsetAllSecrets(); + vi.stubEnv("SYSTEM_USER_PRIVATE_KEY", "system-user-private-key"); + + expect(verifySession(signSession(session))).toBe(true); + }); + + test("falls back to SYSTEM_USER_PRIVATE_KEY_FILE and ZITADEL_LOGINCLIENT_KEYFILE", () => { + const dir = mkdtempSync(join(tmpdir(), "session-cookie-signature-")); + const systemKeyFile = join(dir, "system.key"); + const loginClientKeyFile = join(dir, "login-client.key"); + writeFileSync(systemKeyFile, "system-user-private-key-from-file\n"); + writeFileSync(loginClientKeyFile, "login-client-key-from-file\n"); + + unsetAllSecrets(); + vi.stubEnv("SYSTEM_USER_PRIVATE_KEY_FILE", systemKeyFile); + expect(getSessionCookieSecrets()).toEqual(["system-user-private-key-from-file"]); + expect(verifySession(signSession(session))).toBe(true); + + unsetAllSecrets(); + vi.stubEnv("ZITADEL_LOGINCLIENT_KEYFILE", loginClientKeyFile); + expect(getSessionCookieSecrets()).toEqual(["login-client-key-from-file"]); + expect(verifySession(signSession(session))).toBe(true); + }); + + test("ignores an unreadable key file", () => { + unsetAllSecrets(); + vi.stubEnv("ZITADEL_LOGINCLIENT_KEYFILE", "/does/not/exist.key"); + + expect(hasSessionCookieSecret()).toBe(false); + expect(() => signSession(session)).toThrow(/signing secret is not configured/); + }); + + test("keeps cookies valid while migrating between credential types", () => { + unsetAllSecrets(); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + const signedWithPat = signSession(session); + + // both credentials configured during the migration + vi.stubEnv("SYSTEM_USER_PRIVATE_KEY", "system-user-private-key"); + expect(verifySession(signedWithPat)).toBe(true); + }); + + test("a dedicated secret takes precedence over API credentials", () => { + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + + expect(getSessionCookieSecrets()[0]).toBe("test-session-cookie-secret-at-least-32-chars"); + }); + + test("does not verify when no signing secret is configured", () => { + const signed = signSession(session); + unsetAllSecrets(); + + expect(hasSessionCookieSecret()).toBe(false); + expect(verifySession(signed)).toBe(false); + expect(() => signSession(session)).toThrow(/signing secret is not configured/); + }); + + test("rejects a dedicated secret shorter than the minimum length instead of falling back", () => { + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "too-short"); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + + expect(getSessionCookieSecretConfigError()).toMatch(/at least 32 characters/); + expect(getSessionCookieSecrets()).toEqual([]); + expect(hasSessionCookieSecret()).toBe(false); + expect(() => signSession(session)).toThrow(/at least 32 characters/); + }); + + test("rejects the rotation list if any value is shorter than the minimum length", () => { + const signed = signSession(session); + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", `${NEW_SECRET},short`); + + expect(getSessionCookieSecretConfigError()).toMatch(/1 value\(s\) are shorter/); + expect(verifySession(signed)).toBe(false); + }); + + test("accepts a dedicated secret of exactly the minimum length", () => { + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "x".repeat(MIN_SESSION_COOKIE_SECRET_LENGTH)); + + expect(getSessionCookieSecretConfigError()).toBeUndefined(); + expect(verifySession(signSession(session))).toBe(true); + }); + + test("does not apply the minimum length to API credentials", () => { + unsetAllSecrets(); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "short-token"); + + expect(getSessionCookieSecretConfigError()).toBeUndefined(); + expect(verifySession(signSession(session))).toBe(true); + }); + + describe("startup notice", () => { + test("is empty when a dedicated secret is configured", () => { + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + + expect(isUsingCredentialFallback()).toBe(false); + expect(getSessionCookieSecretStartupNotice()).toBeUndefined(); + }); + + test("warns that the credential fallback is deprecated", () => { + unsetAllSecrets(); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + + expect(isUsingCredentialFallback()).toBe(true); + const notice = getSessionCookieSecretStartupNotice(); + expect(notice?.level).toBe("warn"); + expect(notice?.message).toMatch(/Rotating that credential signs all users out/); + expect(notice?.message).toMatch(/deprecated/); + }); + + test("reports an error for a too short dedicated secret", () => { + vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "too-short"); + vi.stubEnv("ZITADEL_SERVICE_USER_TOKEN", "service-user-token"); + + expect(getSessionCookieSecretStartupNotice()).toEqual({ + level: "error", + message: expect.stringMatching(/at least 32 characters/), + }); + }); + + test("reports an error when no secret is available at all", () => { + unsetAllSecrets(); + + expect(getSessionCookieSecretStartupNotice()?.level).toBe("error"); + }); + }); +}); diff --git a/apps/login/src/lib/session-cookie-signature.ts b/apps/login/src/lib/session-cookie-signature.ts new file mode 100644 index 0000000000..5e97ced7f1 --- /dev/null +++ b/apps/login/src/lib/session-cookie-signature.ts @@ -0,0 +1,237 @@ +import { createHmac, hkdfSync, timingSafeEqual } from "crypto"; +import { readFileSync } from "fs"; + +const SIGNATURE_VERSION = "v1"; +// Domain separation: the derived key is only ever used for session cookie signatures, +// even when the underlying secret is an API credential (PAT / private key). +const HKDF_INFO = `zitadel-login-session-cookie-${SIGNATURE_VERSION}`; +const DERIVED_KEY_LENGTH = 32; + +export type SignableSession = { + id: string; + token: string; + sig?: string; +}; + +// Keys are only loaded once from disk per process (same as api.ts). +const fileCache = new Map(); +const derivedKeyCache = new Map(); + +function readSecretFile(path: string): string | undefined { + if (!fileCache.has(path)) { + try { + fileCache.set(path, readFileSync(path, "utf-8").trim() || undefined); + } catch { + fileCache.set(path, undefined); + } + } + return fileCache.get(path); +} + +function deriveKey(secret: string): Buffer { + let key = derivedKeyCache.get(secret); + if (!key) { + key = Buffer.from(hkdfSync("sha256", secret, "", HKDF_INFO, DERIVED_KEY_LENGTH)); + derivedKeyCache.set(secret, key); + } + return key; +} + +/** + * Minimum length of each ZITADEL_SESSION_COOKIE_SECRET value. API credentials carry enough entropy + * on their own; a typed secret does not, and HKDF cannot add entropy to a weak input. + */ +export const MIN_SESSION_COOKIE_SECRET_LENGTH = 32; + +function getDedicatedSecrets(): string[] { + return (process.env.ZITADEL_SESSION_COOKIE_SECRET ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function getCredentialSecrets(): string[] { + return [ + process.env.ZITADEL_SERVICE_USER_TOKEN, + process.env.SYSTEM_USER_PRIVATE_KEY, + process.env.SYSTEM_USER_PRIVATE_KEY_FILE ? readSecretFile(process.env.SYSTEM_USER_PRIVATE_KEY_FILE) : undefined, + process.env.ZITADEL_LOGINCLIENT_KEYFILE ? readSecretFile(process.env.ZITADEL_LOGINCLIENT_KEYFILE) : undefined, + ].filter((s): s is string => !!s); +} + +/** + * Returns an error message if ZITADEL_SESSION_COOKIE_SECRET is set but not usable, otherwise undefined. + */ +export function getSessionCookieSecretConfigError(): string | undefined { + const tooShort = getDedicatedSecrets().filter((s) => s.length < MIN_SESSION_COOKIE_SECRET_LENGTH).length; + if (tooShort > 0) { + return ( + `ZITADEL_SESSION_COOKIE_SECRET is invalid: every value must be at least ${MIN_SESSION_COOKIE_SECRET_LENGTH} characters long ` + + `(${tooShort} value(s) are shorter). Generate one with \`openssl rand -base64 32\`.` + ); + } + return undefined; +} + +/** + * Returns all secrets that may be used to sign / verify session cookie entries, in priority order. + * + * - ZITADEL_SESSION_COOKIE_SECRET: dedicated secret(s), the recommended configuration. A comma-separated + * list is supported so a secret can be rotated without invalidating existing cookies: the first entry + * is used for signing, all entries are accepted for verification. Every entry must be at least + * MIN_SESSION_COOKIE_SECRET_LENGTH characters long; if any is shorter, no secret is returned at all + * (signing fails and /ready reports not ready) instead of silently falling back to the credential. + * - Otherwise (deprecated fallback) the API credential of the login is used, so applying the security + * patch does not require new configuration. All configured credentials are accepted for verification, + * which keeps cookies valid while a deployment migrates from one credential type to another + * (e.g. PAT -> login client key). This fallback will be removed in a future major release. + */ +export function getSessionCookieSecrets(): string[] { + if (getSessionCookieSecretConfigError()) { + return []; + } + + const dedicated = getDedicatedSecrets(); + const credentials = getCredentialSecrets(); + + return Array.from(new Set([...dedicated, ...credentials])); +} + +/** + * True if cookies are signed with a key derived from the API credential because no dedicated + * ZITADEL_SESSION_COOKIE_SECRET is configured (deprecated fallback). + */ +export function isUsingCredentialFallback(): boolean { + return getDedicatedSecrets().length === 0 && getCredentialSecrets().length > 0; +} + +/** + * Describes problems with the session cookie signing configuration, to be logged once at startup. + */ +export function getSessionCookieSecretStartupNotice(): { level: "error" | "warn"; message: string } | undefined { + const configError = getSessionCookieSecretConfigError(); + if (configError) { + return { level: "error", message: `${configError} The Login UI cannot sign session cookies and reports not ready.` }; + } + if (!hasSessionCookieSecret()) { + return { + level: "error", + message: + "No session cookie signing secret available. Set ZITADEL_SESSION_COOKIE_SECRET. The Login UI reports not ready until a secret is configured.", + }; + } + if (isUsingCredentialFallback()) { + return { + level: "warn", + message: + "ZITADEL_SESSION_COOKIE_SECRET is not set; the session cookie signing key is derived from the configured API credential. " + + "Rotating that credential signs all users out of the Login UI. Set ZITADEL_SESSION_COOKIE_SECRET. " + + "This fallback is deprecated and will be removed in a future release.", + }; + } + return undefined; +} + +export function hasSessionCookieSecret(): boolean { + return getSessionCookieSecrets().length > 0; +} + +/** + * Canonical representation of a cookie entry for signing. All fields (except `sig`) are covered, + * so no part of an entry can be tampered with. Keys are sorted and undefined values are dropped to + * make the payload independent of key order and of the JSON round-trip through the cookie + * (JSON.stringify omits undefined values, so `organization: undefined` and a missing key are equal). + */ +function canonicalPayload(session: SignableSession): string { + const { sig: _ignored, ...fields } = session; + const sorted = Object.fromEntries( + Object.keys(fields) + .sort() + .filter((key) => (fields as Record)[key] !== undefined) + .map((key) => [key, (fields as Record)[key]]), + ); + return JSON.stringify([SIGNATURE_VERSION, sorted]); +} + +function computeSignature(session: SignableSession, secret: string): string { + return createHmac("sha256", deriveKey(secret)).update(canonicalPayload(session)).digest("base64url"); +} + +function signaturesEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + if (leftBytes.length !== rightBytes.length) { + return false; + } + return timingSafeEqual(leftBytes, rightBytes); +} + +export function signSession(session: T): T & { sig: string } { + if (!session.id || !session.token) { + throw new Error("Session cookie entries require a non-empty id and token to be signed."); + } + + const [secret] = getSessionCookieSecrets(); + if (!secret) { + throw new Error( + getSessionCookieSecretConfigError() ?? + "Session cookie signing secret is not configured. Set ZITADEL_SESSION_COOKIE_SECRET (at least 32 characters, e.g. `openssl rand -base64 32`).", + ); + } + + const { sig: _ignored, ...unsigned } = session; + return { + ...(unsigned as T), + sig: computeSignature(unsigned, secret), + }; +} + +export function verifySession(session: unknown): session is SignableSession & { sig: string } { + if (!session || typeof session !== "object") { + return false; + } + + const candidate = session as SignableSession; + if ( + typeof candidate.id !== "string" || + !candidate.id || + typeof candidate.token !== "string" || + !candidate.token || + typeof candidate.sig !== "string" || + !candidate.sig + ) { + return false; + } + + const { sig, ...unsigned } = candidate; + // check every candidate secret (no early return) to keep timing independent of which one matches + let valid = false; + for (const secret of getSessionCookieSecrets()) { + if (signaturesEqual(sig, computeSignature(unsigned, secret))) { + valid = true; + } + } + return valid; +} + +export function stripSessionSignature(session: T): Omit { + const { sig: _ignored, ...unsigned } = session; + return unsigned; +} + +export function parseAndVerifySessions(value: string | undefined): T[] { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(verifySession).map((session) => stripSessionSignature(session as T) as T); + } catch { + return []; + } +} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 3e6c5c477f..049f22e7c9 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -1,6 +1,6 @@ # ⚠️ INSECURE DEFAULTS — for local development only. # Before exposing this stack to a network, change ZITADEL_MASTERKEY, -# POSTGRES_ADMIN_PASSWORD, and POSTGRES_ZITADEL_PASSWORD. +# LOGIN_SESSION_COOKIE_SECRET, POSTGRES_ADMIN_PASSWORD, and POSTGRES_ZITADEL_PASSWORD. # See: https://zitadel.com/docs/self-hosting/deploy/compose#homelab # ----------------------------- @@ -21,6 +21,9 @@ ZITADEL_PUBLIC_SCHEME=http # Must be exactly 32 chars for new deployments. ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z +# Signs the Login UI session cookie. Must be at least 32 chars. +# Generate with: openssl rand -base64 32 +LOGIN_SESSION_COOKIE_SECRET=SessionCookieSecretNeedsAtLeast32Chars # ----------------------------- # Pinned image tags diff --git a/deploy/compose/.env.test b/deploy/compose/.env.test index 6ba54ed98b..d26de7d29c 100644 --- a/deploy/compose/.env.test +++ b/deploy/compose/.env.test @@ -10,6 +10,7 @@ ZITADEL_PUBLIC_SCHEME=http ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z +LOGIN_SESSION_COOKIE_SECRET=SessionCookieSecretNeedsAtLeast32Chars # Suppresses interpolation warnings from the base compose file. # The actual image tags for zitadel-api and zitadel-login are overridden diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 93a53d5f3d..c1d836c2b5 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -128,6 +128,8 @@ services: ZITADEL_API_URL: http://zitadel-api:8080 NEXT_PUBLIC_BASE_PATH: /ui/v2/login ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat + # Signs the Login UI session cookie. At least 32 characters, e.g. `openssl rand -base64 32`. + ZITADEL_SESSION_COOKIE_SECRET: ${LOGIN_SESSION_COOKIE_SECRET} CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME} # Future-ready placeholders for upcoming Login OTEL support.