mirror of
https://github.com/zitadel/zitadel.git
synced 2026-10-25 07:37:43 -05:00
fix(login): sign session cookie entries
The Login UI trusted the session entries stored in its `sessions` cookie
without any integrity protection, so a client could inject or modify
entries and have the Login UI act on sessions of other users.
Every cookie entry is now signed with an HMAC-SHA256 key derived via
HKDF from ZITADEL_SESSION_COOKIE_SECRET. Unsigned or tampered entries
are ignored. The secret must be at least 32 characters, otherwise /ready
reports not ready, and a comma-separated list is accepted for rotation.
If the secret is unset, the key is derived from the configured API
credential and a deprecation warning is logged at startup.
The compose deployment and the self-hosting docs set the new secret.
Upgrade notes: set ZITADEL_SESSION_COOKIE_SECRET (openssl rand -base64 32)
to the same value on all Login UI replicas; only the compose deployment
sets it so far, other deployments use the deprecated fallback. Existing
cookie entries are unsigned and therefore ignored, so users have to sign
in to the Login UI once more after the upgrade.
(cherry picked from commit 0e0f1e96d4)
This commit is contained in:
1 parent
450c0560bc
commit
6e4a3d4822
34 files changed
+871
-118
No files matched your search
@@ -88,12 +88,15 @@ The masterkey [encrypts sensitive data at rest](/concepts/architecture/secrets).
|
||||
ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
|
||||
echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .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 </dev/urandom | head -c 32)" >> .env
|
||||
echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .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!
|
||||
|
||||
@@ -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=<new-secret>,<old-secret>
|
||||
# after the longest session lifetime has passed, drop the old secret
|
||||
ZITADEL_SESSION_COOKIE_SECRET=<new-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
|
||||
|
||||
<Callout type="warn">
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"noUserCode": "لم يتم تقديم رمز مستخدم!",
|
||||
"noDeviceRequest": "لم يتم العثور على طلب جهاز.",
|
||||
"unknownContext": "تعذر الحصول على سياق المستخدم. تأكد من إدخال اسم المستخدم أولاً أو تقديم loginName كمعلمة بحث.",
|
||||
"sessionExpired": "انتهت صلاحية جلستك الحالية. يرجى تسجيل الدخول مرة أخرى.",
|
||||
"sessionExpired": "انتهت صلاحية جلستك أو تتطلب إعادة المصادقة. يرجى تسجيل الدخول مرة أخرى.",
|
||||
"failedLoading": "فشل تحميل البيانات. يرجى المحاولة مرة أخرى.",
|
||||
"tryagain": "حاول مرة أخرى",
|
||||
"couldNotContinueSession": "تعذر المتابعة مع الجلسة - معلومات المستخدم مفقودة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -460,7 +460,7 @@
|
||||
"noUserCode": "ユーザーコードが指定されていません!",
|
||||
"noDeviceRequest": "デバイスリクエストが見つかりません。",
|
||||
"unknownContext": "ユーザーのコンテキストを取得できませんでした。最初にユーザー名を入力するか、検索パラメータとしてloginNameを指定してください。",
|
||||
"sessionExpired": "現在のセッションが期限切れです。再度ログインしてください。",
|
||||
"sessionExpired": "セッションが期限切れか、再認証が必要です。再度ログインしてください。",
|
||||
"failedLoading": "データの読み込みに失敗しました。再試行してください。",
|
||||
"tryagain": "再試行",
|
||||
"couldNotContinueSession": "セッションを継続できませんでした - ユーザー情報が不足しています",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"noUserCode": "Не указан код пользователя!",
|
||||
"noDeviceRequest": "Не найдена ни одна заявка на устройство.",
|
||||
"unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.",
|
||||
"sessionExpired": "Ваша сессия истекла. Войдите снова.",
|
||||
"sessionExpired": "Ваша сессия истекла или требует повторной аутентификации. Войдите снова.",
|
||||
"failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.",
|
||||
"tryagain": "Попробовать снова",
|
||||
"couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"noUserCode": "Код користувача не надано!",
|
||||
"noDeviceRequest": "Запит пристрою не знайдено.",
|
||||
"unknownContext": "Не вдалося отримати контекст користувача. Переконайтеся, що спочатку ввели ім'я користувача або вкажіть loginName як параметр пошуку.",
|
||||
"sessionExpired": "Ваш поточний сеанс закінчився. Будь ласка, увійдіть знову.",
|
||||
"sessionExpired": "Ваш сеанс закінчився або потребує повторної автентифікації. Будь ласка, увійдіть знову.",
|
||||
"failedLoading": "Не вдалося завантажити дані. Будь ласка, спробуйте ще раз.",
|
||||
"tryagain": "Спробувати ще раз",
|
||||
"couldNotContinueSession": "Не вдалося продовжити сеанс - відсутня інформація про користувача",
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"noUserCode": "未提供用户代码!",
|
||||
"noDeviceRequest": "没有找到设备请求。",
|
||||
"unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。",
|
||||
"sessionExpired": "当前会话已过期,请重新登录。",
|
||||
"sessionExpired": "会话已过期或需要重新验证身份,请重新登录。",
|
||||
"failedLoading": "加载数据失败,请再试一次。",
|
||||
"tryagain": "重试",
|
||||
"couldNotContinueSession": "无法继续会话 - 缺少用户信息",
|
||||
|
||||
Vendored
+13
@@ -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
|
||||
|
||||
@@ -89,7 +89,8 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
if (!sessionWithData || !sessionWithData.factors || !sessionWithData.factors.user) {
|
||||
return (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
{/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */}
|
||||
<Translated i18nKey={loginName || sessionId ? "sessionExpired" : "unknownContext"} namespace="error" />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ export default async function Page(props: {
|
||||
{!session && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
{/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */}
|
||||
<Translated i18nKey={loginName || sessionId ? "sessionExpired" : "unknownContext"} namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -135,7 +135,8 @@ export default async function Page(props: {
|
||||
{!session && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
{/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */}
|
||||
<Translated i18nKey={loginName || sessionId ? "sessionExpired" : "unknownContext"} namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,7 +95,8 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
{!session && !user && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
{/* context was provided but the session could not be resolved (cookie missing, invalid or expired) */}
|
||||
<Translated i18nKey={loginName || userId ? "sessionExpired" : "unknownContext"} namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,7 +72,8 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
{(!sessionFactors || !enrollmentAuthorized) && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
{/* a loginName was provided but the session could not be resolved (cookie missing, invalid or expired) */}
|
||||
<Translated i18nKey={!sessionFactors && loginName ? "sessionExpired" : "unknownContext"} namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,10 @@ export async function register(): Promise<void> {
|
||||
_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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Cookie & { sig: string }> {
|
||||
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<T>: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T>(sessions: SessionCookie<T>[], 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<T>(value: string | undefined): SessionCookie<T>[] {
|
||||
const sessions = parseAndVerifySessions<SessionCookie<T>>(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<T>({
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
let currentSessions: SessionCookie<T>[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [];
|
||||
let currentSessions: SessionCookie<T>[] = readSessionCookies<T>(stringifiedCookie?.value);
|
||||
|
||||
const index = currentSessions.findIndex((s) => s.loginName === session.loginName);
|
||||
|
||||
@@ -85,7 +110,8 @@ export async function addSessionToCookie<T>({
|
||||
} 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<T>({
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
const sessions: SessionCookie<T>[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [session];
|
||||
const sessions: SessionCookie<T>[] = stringifiedCookie?.value ? readSessionCookies<T>(stringifiedCookie.value) : [session];
|
||||
|
||||
const foundIndex = sessions.findIndex((session) => session.id === id);
|
||||
|
||||
@@ -152,7 +178,7 @@ export async function removeSessionFromCookie<T>({
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
const sessions: SessionCookie<T>[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [session];
|
||||
const sessions: SessionCookie<T>[] = stringifiedCookie?.value ? readSessionCookies<T>(stringifiedCookie.value) : [session];
|
||||
|
||||
const reducedSessions = sessions.filter((s) => s.id !== session.id);
|
||||
if (cleanup) {
|
||||
@@ -170,17 +196,14 @@ export async function getMostRecentSessionCookie<T>(): Promise<Cookie | undefine
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
if (stringifiedCookie?.value) {
|
||||
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
|
||||
|
||||
const latest = sessions.reduce((prev, current) => {
|
||||
return prev.changeTs > current.changeTs ? prev : current;
|
||||
});
|
||||
|
||||
return latest;
|
||||
} else {
|
||||
const sessions = readSessionCookies<T>(stringifiedCookie?.value);
|
||||
if (!sessions.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sessions.reduce((prev, current) => {
|
||||
return prev.changeTs > current.changeTs ? prev : current;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSessionCookieById<T>({
|
||||
@@ -193,20 +216,8 @@ export async function getSessionCookieById<T>({
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
if (stringifiedCookie?.value) {
|
||||
const sessions: SessionCookie<T>[] = 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<T>(stringifiedCookie?.value);
|
||||
return sessions.find((s) => (organization ? s.organization === organization && s.id === sessionId : s.id === sessionId));
|
||||
}
|
||||
|
||||
export async function getSessionCookieByLoginName<T>({
|
||||
@@ -219,11 +230,7 @@ export async function getSessionCookieByLoginName<T>({
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
if (!stringifiedCookie?.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
|
||||
const sessions = readSessionCookies<T>(stringifiedCookie?.value);
|
||||
return sessions.find((s) =>
|
||||
organization ? s.organization === organization && s.loginName === loginName : s.loginName === loginName,
|
||||
);
|
||||
@@ -248,13 +255,13 @@ export async function getAllSessions<T>(cleanup: boolean = false): Promise<Sessi
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
if (!stringifiedCookie?.value) {
|
||||
const sessions = readSessionCookies<T>(stringifiedCookie?.value);
|
||||
|
||||
if (!sessions.length) {
|
||||
logger.info("getAllSessions: No session cookie found, returning empty array");
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
|
||||
|
||||
if (cleanup) {
|
||||
const now = new Date();
|
||||
return sessions.filter((session) =>
|
||||
@@ -281,12 +288,12 @@ export async function getMostRecentCookieWithLoginname<T>({
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
if (!stringifiedCookie?.value) {
|
||||
const sessions = readSessionCookies<T>(stringifiedCookie?.value);
|
||||
|
||||
if (!sessions.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie.value);
|
||||
|
||||
let filtered = sessions;
|
||||
|
||||
if (loginName) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>();
|
||||
const derivedKeyCache = new Map<string, Buffer>();
|
||||
|
||||
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<string, unknown>)[key] !== undefined)
|
||||
.map((key) => [key, (fields as Record<string, unknown>)[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<T extends SignableSession>(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<T extends SignableSession>(session: T): Omit<T, "sig"> {
|
||||
const { sig: _ignored, ...unsigned } = session;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function parseAndVerifySessions<T extends SignableSession>(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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user