fix(login): sign user verification check and IdP link proof with server secret

The Login UI remembered a successful email or invite verification with a
`verificationCheck` cookie holding sha256(userId:fingerprintId). All
inputs come from the client, so the value could be computed offline and
used in place of a verification code.

The proof is now an HMAC keyed with a server-only secret derived from
ZITADEL_SESSION_COOKIE_SECRET, bound to the user, the browser fingerprint
and an expiry checked on the server (5 minutes), with a separate key per
purpose. Values in the old format are rejected. The proof used when
linking an identity provider to a signed-in session is signed the same
way (15 minutes).

Upgrade notes: users in the middle of an email/invite verification or an
IdP linking during the rollout have to repeat that step. Set a dedicated
ZITADEL_SESSION_COOKIE_SECRET (openssl rand -base64 32).

(cherry picked from commit 18e0089e3f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Max PeintnerandClaude Opus 5.5 authored and Livio Spring committed 2026-10-15 09:43:42 +02:00
1 parent 6493aa908c
commit e32ba7aa69
17 files changed
+504 -131

No files matched your search

@@ -42,6 +42,8 @@ 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.
The same secret also signs the short-lived (5 minutes) proof that a user just verified their email or invite code, so a user who is in the middle of that step during the rotation has to request a new code.
Likewise, it signs the short-lived (15 minutes) proof that ties linking an identity provider to the signed-in session, so a user linking an identity provider during the rotation has to start the linking again.
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.
+4 -2
View File
@@ -25,14 +25,16 @@ declare namespace NodeJS {
ZITADEL_SERVICE_USER_TOKEN: string;
/**
* Secret used to sign LoginV2 `sessions` cookie entries (a signing key is derived via HKDF).
* Secret used to sign LoginV2 `sessions` cookie entries, the `verificationCheck` cookie and the
* `linkFingerprint` of IdP linking flows (purpose-bound signing keys are 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.
* Unsigned or tampered cookie entries are ignored so a client cannot patch an arbitrary session
* or claim a user verification (email / invite code) that never happened.
* All replicas of a deployment must resolve the same secret.
*/
ZITADEL_SESSION_COOKIE_SECRET?: string;
+60
View File
@@ -0,0 +1,60 @@
import { createHash } from "crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createIdpLinkProof, IDP_LINK_PROOF_MAX_AGE_SECONDS, isIdpLinkProofValid } from "./idp-link-proof";
import { createVerificationCheck } from "./verification-check";
vi.mock("next/headers", () => ({ cookies: vi.fn() }));
vi.mock("./fingerprint", () => ({ getFingerprintIdCookie: vi.fn(), getOrSetFingerprintId: vi.fn() }));
describe("idp-link-proof", () => {
beforeEach(() => {
vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "test-session-cookie-secret-at-least-32-chars");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("accepts a proof issued for the same session and fingerprint", () => {
const value = createIdpLinkProof("session-1", "fp-1");
expect(isIdpLinkProofValid(value, "session-1", "fp-1")).toBe(true);
});
it("rejects a proof for another session or another fingerprint", () => {
const value = createIdpLinkProof("session-1", "fp-1");
expect(isIdpLinkProofValid(value, "session-2", "fp-1")).toBe(false);
expect(isIdpLinkProofValid(value, "session-1", "fp-2")).toBe(false);
expect(isIdpLinkProofValid(value, undefined, "fp-1")).toBe(false);
});
it("rejects the legacy, client computable sha256(sessionId + fingerprintId) value", () => {
const forged = createHash("sha256")
.update("session-1" + "fp-1")
.digest("hex");
expect(isIdpLinkProofValid(forged, "session-1", "fp-1")).toBe(false);
});
it("rejects a value signed with a secret the server does not know", () => {
const value = createIdpLinkProof("session-1", "fp-1");
vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "another-secret-".padEnd(32, "x"));
expect(isIdpLinkProofValid(value, "session-1", "fp-1")).toBe(false);
});
it("enforces the expiry server side", () => {
const now = Date.now();
const value = createIdpLinkProof("session-1", "fp-1", now);
expect(isIdpLinkProofValid(value, "session-1", "fp-1", now + (IDP_LINK_PROOF_MAX_AGE_SECONDS - 1) * 1000)).toBe(true);
expect(isIdpLinkProofValid(value, "session-1", "fp-1", now + (IDP_LINK_PROOF_MAX_AGE_SECONDS + 1) * 1000)).toBe(false);
});
it("is not interchangeable with a verification check for the same values", () => {
const now = Date.now();
// a verification check with the same fields and the same expiry must not be accepted as link proof
const verificationCheck = createVerificationCheck(
"session-1",
"fp-1",
now + (IDP_LINK_PROOF_MAX_AGE_SECONDS - 300) * 1000,
);
expect(isIdpLinkProofValid(verificationCheck, "session-1", "fp-1", now)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { createSignedProof, isSignedProofValid } from "./signed-proof";
// covers the round trip to the external IdP, including a login (and MFA) there
export const IDP_LINK_PROOF_MAX_AGE_SECONDS = 900; // 15 minutes
/**
* Creates the `linkFingerprint` passed through the IdP flow when a signed-in user links an IdP to
* their account. It binds the session to link to the browser that started the flow and is signed
* with a server-only secret, so it can't be computed by a client for another session.
*/
export function createIdpLinkProof(sessionId: string, fingerprintId: string, now: number = Date.now()): string {
return createSignedProof("idp-link", [sessionId, fingerprintId], IDP_LINK_PROOF_MAX_AGE_SECONDS, now);
}
export function isIdpLinkProofValid(
value: string | undefined,
sessionId: string | undefined,
fingerprintId: string | undefined,
now: number = Date.now(),
): boolean {
return isSignedProofValid("idp-link", value, [sessionId, fingerprintId], now);
}
+34 -10
View File
@@ -2,6 +2,7 @@ import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import crypto from "crypto";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createIdpLinkProof } from "../idp-link-proof";
import { processIDPCallback } from "./idp-intent";
// Mock all the dependencies
@@ -523,18 +524,16 @@ describe("processIDPCallback", () => {
describe("CASE 2: Link IDP to existing user", () => {
const sessionId = "session123";
const fingerprintValue = "fp123";
const linkFingerprint = crypto
.createHash("sha256")
.update(sessionId + fingerprintValue)
.digest("hex");
const linkParams = {
...defaultParams,
sessionId,
linkFingerprint,
};
let linkParams: typeof defaultParams & { sessionId: string; linkFingerprint: string };
beforeEach(() => {
vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "test-session-cookie-secret-at-least-32-chars");
linkParams = {
...defaultParams,
sessionId,
linkFingerprint: createIdpLinkProof(sessionId, fingerprintValue),
};
mockGetFingerprintIdCookie.mockResolvedValue({ name: "fingerprintId", value: fingerprintValue });
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
@@ -552,6 +551,31 @@ describe("processIDPCallback", () => {
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
test.each([
[
"the legacy, client computable sha256(sessionId + fingerprint)",
() =>
crypto
.createHash("sha256")
.update(sessionId + fingerprintValue)
.digest("hex"),
],
["a proof for another session", () => createIdpLinkProof("other-session", fingerprintValue)],
["a proof for another browser", () => createIdpLinkProof(sessionId, "other-fp")],
])("should reject %s as linkFingerprint", async (_name, makeLinkFingerprint) => {
mockGetIDPByID.mockResolvedValue(defaultIdp);
const result = await processIDPCallback({ ...linkParams, linkFingerprint: makeLinkFingerprint() });
expect(result.redirect).toContain("/idp/google/linking-failed");
expect(result.redirect).toContain("error=session_mismatch");
expect(mockAddIDPLink).not.toHaveBeenCalled();
});
test("should link IDP and create session when linking is allowed", async () => {
mockGetIDPByID.mockResolvedValue({
...defaultIdp,
+2 -7
View File
@@ -29,10 +29,10 @@ import {
UpdateUserRequest,
UpdateUserRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import crypto from "crypto";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { getFingerprintIdCookie } from "../fingerprint";
import { isIdpLinkProofValid } from "../idp-link-proof";
import { getEnrollmentAuthorizationError } from "./enrollment-guard";
import { createNewSessionFromIdpIntent } from "./idp";
import { syncInstanceRolesFromIdpIntent } from "./instance-roles";
@@ -358,12 +358,7 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
return { redirect: `/idp/${provider}/linking-failed?error=session_mismatch` };
}
const expectedHash = crypto
.createHash("sha256")
.update(sessionId + fingerprintCookie.value)
.digest("hex");
if (linkFingerprint !== expectedHash) {
if (!isIdpLinkProofValid(linkFingerprint, sessionId, fingerprintCookie.value)) {
logger.warn("Session linking fingerprint mismatch");
return { redirect: `/idp/${provider}/linking-failed?error=session_mismatch` };
}
+7 -3
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { isIdpLinkProofValid } from "../idp-link-proof";
import { redirectToIdp } from "./idp";
// Mock all the dependencies
@@ -43,6 +44,7 @@ describe("redirectToIdp", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.stubEnv("ZITADEL_SESSION_COOKIE_SECRET", "test-session-cookie-secret-at-least-32-chars");
// Import mocked modules
const { headers } = await import("next/headers");
@@ -76,6 +78,7 @@ describe("redirectToIdp", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
describe("postErrorRedirectUrl parameter handling", () => {
@@ -239,9 +242,10 @@ describe("redirectToIdp", () => {
// Both parameters should be present
// Both parameters should be present
expect(successUrl).toContain("linkToSessionId=session123");
// We can't easily predict the exact fingerprint hash without mocking crypto in this test,
// but we can check if the parameter key exists
expect(successUrl).toContain("linkFingerprint=");
// The linkFingerprint is a signed proof bound to the session and this browser's fingerprint
const linkFingerprint = new URL(successUrl).searchParams.get("linkFingerprint") ?? undefined;
expect(isIdpLinkProofValid(linkFingerprint, "session123", "fp123")).toBe(true);
expect(isIdpLinkProofValid(linkFingerprint, "other-session", "fp123")).toBe(false);
expect(successUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
expect(failureUrl).toContain("linkToSessionId=session123");
expect(failureUrl).toContain("linkFingerprint=");
+3 -6
View File
@@ -8,12 +8,12 @@ import {
startIdentityProviderFlow,
startLDAPIdentityProviderFlow,
} from "@/lib/zitadel";
import crypto from "crypto";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { completeFlowOrGetUrl } from "../client";
import { getSessionCookieById } from "../cookies";
import { getOrSetFingerprintId } from "../fingerprint";
import { createIdpLinkProof } from "../idp-link-proof";
import { getServiceConfig } from "../service-url";
import { checkEmailVerification, checkMFAFactors } from "../verify-helper";
import { createSessionForIdpAndUpdateCookie } from "./cookie";
@@ -45,11 +45,8 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For
// Get fingerprint (ensure it exists)
const fingerprintId = await getOrSetFingerprintId();
// Create hash to verify intent upon return
const linkFingerprint = crypto
.createHash("sha256")
.update(sessionId + fingerprintId)
.digest("hex");
// Signed proof (server-only secret) to verify the intent upon return
const linkFingerprint = createIdpLinkProof(sessionId, fingerprintId);
params.set("linkToSessionId", sessionId);
params.set("linkFingerprint", linkFingerprint);
+3 -15
View File
@@ -5,13 +5,12 @@ import { addHumanUser, addIDPLink, getLoginSettings, getUserByID, listAuthentica
import { Code, ConnectError, Duration, create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks, ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import crypto from "crypto";
import { getTranslations } from "next-intl/server";
import { cookies, headers } from "next/headers";
import { headers } from "next/headers";
import { completeFlowOrGetUrl } from "../client";
import { getOrSetFingerprintId } from "../fingerprint";
import { createLogger } from "../logger";
import { getServiceConfig } from "../service-url";
import { setUserVerificationCheckCookie } from "../verification-check";
import { checkEmailVerification, checkMFAFactors } from "../verify-helper";
const logger = createLogger("register");
@@ -136,18 +135,7 @@ export async function registerUser(
// Set verification cookie for users registering with passkey (no password)
// This allows them to proceed with passkey registration without additional verification
const cookiesList = await cookies();
const userAgentId = await getOrSetFingerprintId();
const verificationCheck = crypto.createHash("sha256").update(`${session.factors.user.id}:${userAgentId}`).digest("hex");
await cookiesList.set({
name: "verificationCheck",
value: verificationCheck,
httpOnly: true,
path: "/",
maxAge: 300, // 5 minutes
});
await setUserVerificationCheckCookie(session.factors.user.id);
return { redirect: "/passkey/set?" + params };
} else {
+5 -2
View File
@@ -51,8 +51,8 @@ vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(() => ({ serviceConfig: {} })),
}));
vi.mock("../fingerprint", () => ({
getOrSetFingerprintId: vi.fn(),
vi.mock("../verification-check", () => ({
setUserVerificationCheckCookie: vi.fn(),
}));
vi.mock("./host", () => ({
@@ -131,6 +131,9 @@ describe("sendVerification", () => {
expect(mockGetSession).toHaveBeenCalled();
// Verify creation was attempted
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
// The signed verification check is issued for the verified user
const { setUserVerificationCheckCookie } = await import("../verification-check");
expect(setUserVerificationCheckCookie).toHaveBeenCalledWith("user-1");
});
test("should include requestId in /authenticator/set redirect when provided", async () => {
+5 -16
View File
@@ -12,20 +12,19 @@ import {
verifyTOTPRegistration,
sendEmailCode as zitadelSendEmailCode,
} from "@/lib/zitadel";
import crypto from "crypto";
import { create } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getTranslations } from "next-intl/server";
import { cookies, headers } from "next/headers";
import { headers } from "next/headers";
import { completeFlowOrGetUrl } from "../client";
import { getSessionCookieByLoginName } from "../cookies";
import { getOrSetFingerprintId } from "../fingerprint";
import { checkMFAFactors } from "../mfa-helper";
import { getServiceConfig } from "../service-url";
import { loadMostRecentSession } from "../session";
import { setUserVerificationCheckCookie } from "../verification-check";
import { createSessionAndUpdateCookie } from "./cookie";
import { getEnrollmentAuthorizationError } from "./enrollment-guard";
import { getPublicHostWithProtocol } from "./host";
@@ -172,19 +171,9 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
params.set("requestId", command.requestId);
}
// set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId
const cookiesList = await cookies();
const userAgentId = await getOrSetFingerprintId();
const verificationCheck = crypto.createHash("sha256").update(`${user.userId}:${userAgentId}`).digest("hex");
await cookiesList.set({
name: "verificationCheck",
value: verificationCheck,
httpOnly: true,
path: "/",
maxAge: 300, // 5 minutes
});
// remember the successful verification for this browser (signed with a server-only secret),
// checks are done for users with invalid sessions and invalid userAgentId
await setUserVerificationCheckCookie(user.userId);
return { redirect: `/authenticator/set?${params}` };
}
+28 -13
View File
@@ -28,11 +28,17 @@ function readSecretFile(path: string): string | undefined {
return fileCache.get(path);
}
function deriveKey(secret: string): Buffer {
let key = derivedKeyCache.get(secret);
/**
* Derives a purpose-bound signing key from a secret returned by `getSessionCookieSecrets`.
* `info` provides the domain separation, so keys for different purposes (session cookie entries,
* user verification check, ...) are independent even though they share the same secret.
*/
export function deriveSigningKey(secret: string, info: string): Buffer {
const cacheKey = JSON.stringify([info, secret]);
let key = derivedKeyCache.get(cacheKey);
if (!key) {
key = Buffer.from(hkdfSync("sha256", secret, "", HKDF_INFO, DERIVED_KEY_LENGTH));
derivedKeyCache.set(secret, key);
key = Buffer.from(hkdfSync("sha256", secret, "", info, DERIVED_KEY_LENGTH));
derivedKeyCache.set(cacheKey, key);
}
return key;
}
@@ -154,10 +160,25 @@ function canonicalPayload(session: SignableSession): string {
}
function computeSignature(session: SignableSession, secret: string): string {
return createHmac("sha256", deriveKey(secret)).update(canonicalPayload(session)).digest("base64url");
return createHmac("sha256", deriveSigningKey(secret, HKDF_INFO)).update(canonicalPayload(session)).digest("base64url");
}
function signaturesEqual(left: string, right: string): boolean {
/**
* Returns the secret new signatures are created with (the first of `getSessionCookieSecrets`).
* Throws if no usable secret is configured.
*/
export function getPrimarySigningSecret(): string {
const [secret] = getSessionCookieSecrets();
if (!secret) {
throw new Error(
getSessionCookieSecretConfigError() ??
`Session cookie signing secret is not configured. Set ZITADEL_SESSION_COOKIE_SECRET (at least ${MIN_SESSION_COOKIE_SECRET_LENGTH} characters, e.g. \`openssl rand -base64 32\`).`,
);
}
return secret;
}
export function signaturesEqual(left: string, right: string): boolean {
const leftBytes = Buffer.from(left);
const rightBytes = Buffer.from(right);
if (leftBytes.length !== rightBytes.length) {
@@ -171,13 +192,7 @@ export function signSession<T extends SignableSession>(session: T): T & { sig: s
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 secret = getPrimarySigningSecret();
const { sig: _ignored, ...unsigned } = session;
return {
+80
View File
@@ -0,0 +1,80 @@
import { createHmac } from "crypto";
import {
deriveSigningKey,
getPrimarySigningSecret,
getSessionCookieSecrets,
signaturesEqual,
} from "./session-cookie-signature";
const VERSION = "v1";
/**
* Purposes a signed proof can be issued for. Each purpose gets its own HKDF-derived key, so a proof
* issued for one purpose can never be replayed as a proof for another one.
*/
export type SignedProofPurpose = "verification-check" | "idp-link";
function computeSignature(secret: string, purpose: SignedProofPurpose, fields: string[], expiresAt: number): string {
// JSON array encoding keeps the fields unambiguous (no delimiter injection through any field)
const payload = JSON.stringify([VERSION, ...fields, expiresAt]);
// Domain separation: all proofs share the secret of the session cookie signature
// (ZITADEL_SESSION_COOKIE_SECRET or the API credential) but use their own derived key per purpose.
return createHmac("sha256", deriveSigningKey(secret, `zitadel-login-${purpose}-${VERSION}`))
.update(payload)
.digest("base64url");
}
/**
* Creates a proof `v1.<expiresAt>.<signature>` binding `fields` together until `ttlSeconds` from now.
* The signature is an HMAC keyed with a server-only secret, so the proof can neither be forged by a
* client nor be reused for other field values or after expiry. The value is cookie and URL safe.
*/
export function createSignedProof(
purpose: SignedProofPurpose,
fields: string[],
ttlSeconds: number,
now: number = Date.now(),
): string {
if (fields.length === 0 || fields.some((field) => !field)) {
throw new Error(`All fields are required to create a ${purpose} proof.`);
}
const secret = getPrimarySigningSecret();
const expiresAt = Math.floor(now / 1000) + ttlSeconds;
return `${VERSION}.${expiresAt}.${computeSignature(secret, purpose, fields, expiresAt)}`;
}
export function isSignedProofValid(
purpose: SignedProofPurpose,
value: string | undefined,
fields: (string | undefined)[],
now: number = Date.now(),
): boolean {
if (!value || fields.length === 0 || fields.some((field) => !field)) {
return false;
}
const parts = value.split(".");
if (parts.length !== 3) {
return false;
}
const [version, expiresAtRaw, signature] = parts;
if (version !== VERSION || !/^\d{1,12}$/.test(expiresAtRaw) || !signature) {
return false;
}
const expiresAt = Number(expiresAtRaw);
// 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(signature, computeSignature(secret, purpose, fields as string[], expiresAt))) {
valid = true;
}
}
// expiry is enforced server side, a cookie maxAge is only a hint for the browser
return valid && expiresAt > Math.floor(now / 1000);
}
@@ -0,0 +1,141 @@
import { createHash } from "crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockCookies = { get: vi.fn(), set: vi.fn() };
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => mockCookies),
}));
vi.mock("./fingerprint", () => ({
getFingerprintIdCookie: vi.fn(),
getOrSetFingerprintId: vi.fn(),
}));
import { getFingerprintIdCookie, getOrSetFingerprintId } from "./fingerprint";
import { MIN_SESSION_COOKIE_SECRET_LENGTH, signSession } from "./session-cookie-signature";
import {
createVerificationCheck,
hasValidUserVerificationCheckCookie,
isVerificationCheckValid,
setUserVerificationCheckCookie,
VERIFICATION_CHECK_MAX_AGE_SECONDS,
} from "./verification-check";
const ENV_KEYS = [
"ZITADEL_SESSION_COOKIE_SECRET",
"ZITADEL_SERVICE_USER_TOKEN",
"SYSTEM_USER_PRIVATE_KEY",
"SYSTEM_USER_PRIVATE_KEY_FILE",
"ZITADEL_LOGINCLIENT_KEYFILE",
] as const;
describe("verification-check", () => {
const originalEnv: Record<string, string | undefined> = {};
beforeEach(() => {
vi.clearAllMocks();
for (const key of ENV_KEYS) {
originalEnv[key] = process.env[key];
delete process.env[key];
}
process.env.ZITADEL_SERVICE_USER_TOKEN = "service-user-token";
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
});
it("accepts a check issued for the same user and fingerprint", () => {
const value = createVerificationCheck("user-1", "fp-1");
expect(isVerificationCheckValid(value, "user-1", "fp-1")).toBe(true);
});
it("rejects a check for another user or another fingerprint", () => {
const value = createVerificationCheck("user-1", "fp-1");
expect(isVerificationCheckValid(value, "user-2", "fp-1")).toBe(false);
expect(isVerificationCheckValid(value, "user-1", "fp-2")).toBe(false);
});
it("rejects the legacy, client computable sha256(userId:fingerprintId) value", () => {
const forged = createHash("sha256").update("victim:attacker-fp").digest("hex");
expect(isVerificationCheckValid(forged, "victim", "attacker-fp")).toBe(false);
});
it("rejects a value signed with a secret the server does not know", () => {
const value = createVerificationCheck("user-1", "fp-1");
process.env.ZITADEL_SERVICE_USER_TOKEN = "another-token";
expect(isVerificationCheckValid(value, "user-1", "fp-1")).toBe(false);
});
it("enforces the expiry server side and rejects a tampered expiry", () => {
const now = Date.now();
const value = createVerificationCheck("user-1", "fp-1", now);
expect(isVerificationCheckValid(value, "user-1", "fp-1", now + (VERIFICATION_CHECK_MAX_AGE_SECONDS - 1) * 1000)).toBe(
true,
);
expect(isVerificationCheckValid(value, "user-1", "fp-1", now + (VERIFICATION_CHECK_MAX_AGE_SECONDS + 1) * 1000)).toBe(
false,
);
const [version, expiresAt, signature] = value.split(".");
const extended = `${version}.${Number(expiresAt) + 3600}.${signature}`;
expect(isVerificationCheckValid(extended, "user-1", "fp-1", now)).toBe(false);
});
it("rejects malformed values", () => {
for (const value of [undefined, "", "v1", "v1..", "v2.123.abc", "v1.abc.def", "a.b.c.d"]) {
expect(isVerificationCheckValid(value, "user-1", "fp-1")).toBe(false);
}
});
it("does not let field boundaries be shifted between userId and fingerprintId", () => {
const value = createVerificationCheck("user:1", "fp");
expect(isVerificationCheckValid(value, "user", "1:fp")).toBe(false);
});
it("prefers the dedicated secret and supports rotation", () => {
const oldSecret = "old-secret-".padEnd(MIN_SESSION_COOKIE_SECRET_LENGTH, "o");
const newSecret = "new-secret-".padEnd(MIN_SESSION_COOKIE_SECRET_LENGTH, "n");
process.env.ZITADEL_SESSION_COOKIE_SECRET = oldSecret;
const value = createVerificationCheck("user-1", "fp-1");
process.env.ZITADEL_SESSION_COOKIE_SECRET = `${newSecret}, ${oldSecret}`;
expect(isVerificationCheckValid(value, "user-1", "fp-1")).toBe(true);
});
it("fails closed without any secret", () => {
delete (process.env as Record<string, string | undefined>).ZITADEL_SERVICE_USER_TOKEN;
expect(() => createVerificationCheck("user-1", "fp-1")).toThrow();
expect(isVerificationCheckValid("v1.9999999999.abc", "user-1", "fp-1")).toBe(false);
});
it("uses a key that is independent of the session cookie signature", () => {
const session = signSession({ id: "user-1", token: "fp-1" });
const [, , signature] = createVerificationCheck("user-1", "fp-1").split(".");
expect(signature).not.toBe(session.sig);
});
it("round trips through the cookie helpers", async () => {
vi.mocked(getOrSetFingerprintId).mockResolvedValue("fp-1");
await setUserVerificationCheckCookie("user-1");
expect(mockCookies.set).toHaveBeenCalledWith(
expect.objectContaining({ name: "verificationCheck", httpOnly: true, maxAge: VERIFICATION_CHECK_MAX_AGE_SECONDS }),
);
const { value } = mockCookies.set.mock.calls[0][0];
mockCookies.get.mockReturnValue({ value });
vi.mocked(getFingerprintIdCookie).mockResolvedValue({ name: "fingerprintId", value: "fp-1" } as any);
expect(await hasValidUserVerificationCheckCookie("user-1")).toBe(true);
expect(await hasValidUserVerificationCheckCookie("user-2")).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { cookies } from "next/headers";
import { getFingerprintIdCookie, getOrSetFingerprintId } from "./fingerprint";
import { createSignedProof, isSignedProofValid } from "./signed-proof";
export const VERIFICATION_CHECK_COOKIE = "verificationCheck";
export const VERIFICATION_CHECK_MAX_AGE_SECONDS = 300; // 5 minutes
/**
* Creates the value of the verification check cookie: `v1.<expiresAt>.<signature>`.
* The signature is an HMAC over userId, fingerprintId and expiry, keyed with a server-only secret,
* so it can neither be forged by a client nor be reused for another user, browser or after expiry.
*/
export function createVerificationCheck(userId: string, fingerprintId: string, now: number = Date.now()): string {
return createSignedProof("verification-check", [userId, fingerprintId], VERIFICATION_CHECK_MAX_AGE_SECONDS, now);
}
export function isVerificationCheckValid(
value: string | undefined,
userId: string,
fingerprintId: string | undefined,
now: number = Date.now(),
): boolean {
return isSignedProofValid("verification-check", value, [userId, fingerprintId], now);
}
/**
* Marks the user as verified (email / invite code, or fresh registration) for this browser.
* Must only be called after a genuine, server-side validated verification.
*/
export async function setUserVerificationCheckCookie(userId: string): Promise<void> {
const cookiesList = await cookies();
const fingerprintId = await getOrSetFingerprintId();
await cookiesList.set({
name: VERIFICATION_CHECK_COOKIE,
value: createVerificationCheck(userId, fingerprintId),
httpOnly: true,
path: "/",
maxAge: VERIFICATION_CHECK_MAX_AGE_SECONDS,
});
}
export async function hasValidUserVerificationCheckCookie(userId: string): Promise<boolean> {
const cookiesList = await cookies();
// only read cookie to prevent issues on page.tsx
const fingerprintCookie = await getFingerprintIdCookie();
return isVerificationCheckValid(cookiesList.get(VERIFICATION_CHECK_COOKIE)?.value, userId, fingerprintCookie?.value);
}
+51 -32
View File
@@ -2,6 +2,7 @@ import crypto from "crypto";
import { cookies } from "next/headers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFingerprintIdCookie } from "./fingerprint";
import { createVerificationCheck } from "./verification-check";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import {
@@ -349,21 +350,13 @@ vi.mock("next/headers", () => ({
vi.mock("./fingerprint", () => ({
getFingerprintIdCookie: vi.fn(),
getOrSetFingerprintId: vi.fn(),
}));
vi.mock("./zitadel", () => ({
getUserByID: vi.fn(),
}));
vi.mock("crypto", () => ({
default: {
createHash: vi.fn(() => ({
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => "mockedhash123"),
})),
},
}));
describe("checkPasswordChangeRequired", () => {
const mockSession: any = {
id: "session-123",
@@ -640,23 +633,30 @@ describe("checkEmailVerification", () => {
describe("checkUserVerification", () => {
let mockCookies: any;
const originalToken = process.env.ZITADEL_SERVICE_USER_TOKEN;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.ZITADEL_SERVICE_USER_TOKEN = "server-only-test-secret";
mockCookies = {
get: vi.fn(),
};
vi.mocked(cookies).mockResolvedValue(mockCookies);
});
it("should return true if verification hash matches", async () => {
afterEach(() => {
process.env.ZITADEL_SERVICE_USER_TOKEN = originalToken;
});
it("should return true for a server issued verification check", async () => {
vi.mocked(getFingerprintIdCookie).mockResolvedValue({
name: "fingerprintId",
value: "fingerprint-123",
} as any);
mockCookies.get.mockReturnValue({
value: "mockedhash123",
value: createVerificationCheck("user-123", "fingerprint-123"),
});
const result = await checkUserVerification("user-123");
@@ -664,9 +664,43 @@ describe("checkUserVerification", () => {
expect(result).toBe(true);
});
it("should return false for a verification check issued for another user", async () => {
vi.mocked(getFingerprintIdCookie).mockResolvedValue({
name: "fingerprintId",
value: "fingerprint-123",
} as any);
mockCookies.get.mockReturnValue({
value: createVerificationCheck("attacker-user", "fingerprint-123"),
});
const result = await checkUserVerification("user-123");
expect(result).toBe(false);
});
it("should return false for a client computed sha256(userId:fingerprintId) hash", async () => {
vi.mocked(getFingerprintIdCookie).mockResolvedValue({
name: "fingerprintId",
value: "attacker-chosen",
} as any);
mockCookies.get.mockReturnValue({
value: crypto.createHash("sha256").update("user-123:attacker-chosen").digest("hex"),
});
const result = await checkUserVerification("user-123");
expect(result).toBe(false);
});
it("should return false if fingerprint cookie not found", async () => {
vi.mocked(getFingerprintIdCookie).mockResolvedValue(undefined);
mockCookies.get.mockReturnValue({
value: createVerificationCheck("user-123", "fingerprint-123"),
});
const result = await checkUserVerification("user-123");
expect(result).toBe(false);
@@ -696,34 +730,19 @@ describe("checkUserVerification", () => {
expect(result).toBe(false);
});
it("should return false if verification hash does not match", async () => {
it("should not log the expected value on mismatch", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.mocked(getFingerprintIdCookie).mockResolvedValue({
name: "fingerprintId",
value: "fingerprint-123",
} as any);
mockCookies.get.mockReturnValue({
value: "wronghash",
});
mockCookies.get.mockReturnValue({ value: "wronghash" });
const result = await checkUserVerification("user-123");
expect(await checkUserVerification("user-123")).toBe(false);
expect(result).toBe(false);
});
it("should create hash from userId and fingerprint", async () => {
vi.mocked(getFingerprintIdCookie).mockResolvedValue({
name: "fingerprintId",
value: "fingerprint-456",
} as any);
mockCookies.get.mockReturnValue({
value: "mockedhash123",
});
await checkUserVerification("user-456");
expect(crypto.createHash).toHaveBeenCalledWith("sha256");
const expected = createVerificationCheck("user-123", "fingerprint-123").split(".")[2];
expect(JSON.stringify(warn.mock.calls)).not.toContain(expected);
});
});
+7 -25
View File
@@ -2,11 +2,9 @@ import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb";
import crypto from "crypto";
import moment from "moment";
import { cookies } from "next/headers";
import { getFingerprintIdCookie } from "./fingerprint";
import { trySendVerification } from "./server/verify";
import { hasValidUserVerificationCheckCookie } from "./verification-check";
export function checkPasswordChangeRequired(
expirySettings: PasswordExpirySettings | undefined,
@@ -111,29 +109,13 @@ export async function checkEmailVerification(
export { checkMFAFactors, shouldEnforceMFA } from "./mfa-helper";
export async function checkUserVerification(userId: string): Promise<boolean> {
// check if a verification was done earlier
const cookiesList = await cookies();
// check if a verification was done earlier in this browser. The cookie value is an HMAC keyed with a
// server-only secret (see verification-check.ts), so it can't be computed by the client.
const valid = await hasValidUserVerificationCheckCookie(userId);
// only read cookie to prevent issues on page.tsx
const fingerPrintCookie = await getFingerprintIdCookie();
if (!fingerPrintCookie || !fingerPrintCookie.value) {
return false;
if (!valid) {
console.warn("User verification check failed.");
}
const verificationCheck = crypto.createHash("sha256").update(`${userId}:${fingerPrintCookie.value}`).digest("hex");
const cookieValue = await cookiesList.get("verificationCheck")?.value;
if (!cookieValue) {
console.warn("User verification check cookie not found. User verification check failed.");
return false;
}
if (cookieValue !== verificationCheck) {
console.warn(`User verification check failed. Expected ${verificationCheck} but got ${cookieValue}`);
return false;
}
return true;
return valid;
}