fix(login): Safari Cookie Issues in Development Mode (#10594)

Safari was not creating session cookies during local development,
causing authentication failures. This was due to nextjs default setting
of SameSite cookie property.
We explicitly set "strict" for session cookies now.

Closes #10473 

# Which Problems Are Solved

Authentication Issues with Safari in local development

# How the Problems Are Solved

- Cleaner API: Replaced confusing sameSite boolean/string parameters
with iFrameEnabled boolean
- Better logic flow:

iFrameEnabled: true → sameSite: "none" (for iframe embedding)
Production → sameSite: "strict" (maximum security)
This commit is contained in:
Max Peintner
2025-09-11 17:01:24 +02:00
committed by GitHub
parent 1a42e99329
commit a9cd3ff9c0
3 changed files with 61 additions and 119 deletions

View File

@@ -20,18 +20,26 @@ export type Cookie = {
type SessionCookie<T> = Cookie & T;
async function setSessionHttpOnlyCookie<T>(
sessions: SessionCookie<T>[],
sameSite: boolean | "lax" | "strict" | "none" = true,
) {
async function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[], iFrameEnabled: boolean = false) {
const cookiesList = await cookies();
// Use "none" for iframe compatibility, otherwise "strict" as default
let resolvedSameSite: "lax" | "strict" | "none";
if (iFrameEnabled) {
// When embedded in iframe, must use "none" with secure flag
resolvedSameSite = "none";
} else {
// Production and other environments: use strict for better security
resolvedSameSite = "strict";
}
return cookiesList.set({
name: "sessions",
value: JSON.stringify(sessions),
httpOnly: true,
path: "/",
sameSite: process.env.NODE_ENV === "production" ? sameSite : "lax",
sameSite: resolvedSameSite,
secure: process.env.NODE_ENV === "production",
});
}
@@ -50,22 +58,18 @@ export async function setLanguageCookie(language: string) {
export async function addSessionToCookie<T>({
session,
cleanup,
sameSite,
iFrameEnabled,
}: {
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
iFrameEnabled?: boolean;
}): Promise<any> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
let currentSessions: SessionCookie<T>[] = stringifiedCookie?.value
? JSON.parse(stringifiedCookie?.value)
: [];
let currentSessions: SessionCookie<T>[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [];
const index = currentSessions.findIndex(
(s) => s.loginName === session.loginName,
);
const index = currentSessions.findIndex((s) => s.loginName === session.loginName);
if (index > -1) {
currentSessions[index] = session;
@@ -85,13 +89,11 @@ export async function addSessionToCookie<T>({
if (cleanup) {
const now = new Date();
const filteredSessions = currentSessions.filter((session) =>
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
);
return setSessionHttpOnlyCookie(filteredSessions, sameSite);
return setSessionHttpOnlyCookie(filteredSessions, iFrameEnabled);
} else {
return setSessionHttpOnlyCookie(currentSessions, sameSite);
return setSessionHttpOnlyCookie(currentSessions, iFrameEnabled);
}
}
@@ -99,19 +101,17 @@ export async function updateSessionCookie<T>({
id,
session,
cleanup,
sameSite,
iFrameEnabled,
}: {
id: string;
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
iFrameEnabled?: boolean;
}): Promise<any> {
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 ? JSON.parse(stringifiedCookie?.value) : [session];
const foundIndex = sessions.findIndex((session) => session.id === id);
@@ -120,13 +120,11 @@ export async function updateSessionCookie<T>({
if (cleanup) {
const now = new Date();
const filteredSessions = sessions.filter((session) =>
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
);
return setSessionHttpOnlyCookie(filteredSessions, sameSite);
return setSessionHttpOnlyCookie(filteredSessions, iFrameEnabled);
} else {
return setSessionHttpOnlyCookie(sessions, sameSite);
return setSessionHttpOnlyCookie(sessions, iFrameEnabled);
}
} else {
throw "updateSessionCookie<T>: session id now found";
@@ -136,30 +134,26 @@ export async function updateSessionCookie<T>({
export async function removeSessionFromCookie<T>({
session,
cleanup,
sameSite,
iFrameEnabled,
}: {
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
iFrameEnabled?: boolean;
}) {
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 ? JSON.parse(stringifiedCookie?.value) : [session];
const reducedSessions = sessions.filter((s) => s.id !== session.id);
if (cleanup) {
const now = new Date();
const filteredSessions = reducedSessions.filter((session) =>
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
);
return setSessionHttpOnlyCookie(filteredSessions, sameSite);
return setSessionHttpOnlyCookie(filteredSessions, iFrameEnabled);
} else {
return setSessionHttpOnlyCookie(reducedSessions, sameSite);
return setSessionHttpOnlyCookie(reducedSessions, iFrameEnabled);
}
}
@@ -194,9 +188,7 @@ export async function getSessionCookieById<T>({
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
const found = sessions.find((s) =>
organization
? s.organization === organization && s.id === sessionId
: s.id === sessionId,
organization ? s.organization === organization && s.id === sessionId : s.id === sessionId,
);
if (found) {
return found;
@@ -221,9 +213,7 @@ export async function getSessionCookieByLoginName<T>({
if (stringifiedCookie?.value) {
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
const found = sessions.find((s) =>
organization
? s.organization === organization && s.loginName === loginName
: s.loginName === loginName,
organization ? s.organization === organization && s.loginName === loginName : s.loginName === loginName,
);
if (found) {
return found;
@@ -240,9 +230,7 @@ export async function getSessionCookieByLoginName<T>({
* @param cleanup when true, removes all expired sessions, default true
* @returns Session Cookies
*/
export async function getAllSessionCookieIds<T>(
cleanup: boolean = false,
): Promise<string[]> {
export async function getAllSessionCookieIds<T>(cleanup: boolean = false): Promise<string[]> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -253,9 +241,7 @@ export async function getAllSessionCookieIds<T>(
const now = new Date();
return sessions
.filter((session) =>
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
)
.map((session) => session.id);
} else {
@@ -271,9 +257,7 @@ export async function getAllSessionCookieIds<T>(
* @param cleanup when true, removes all expired sessions, default true
* @returns Session Cookies
*/
export async function getAllSessions<T>(
cleanup: boolean = false,
): Promise<SessionCookie<T>[]> {
export async function getAllSessions<T>(cleanup: boolean = false): Promise<SessionCookie<T>[]> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -283,9 +267,7 @@ export async function getAllSessions<T>(
if (cleanup) {
const now = new Date();
return sessions.filter((session) =>
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
session.expirationTs ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true,
);
} else {
return sessions;

View File

@@ -9,15 +9,8 @@ import {
setSession,
} from "@/lib/zitadel";
import { ConnectError, Duration, timestampMs } from "@zitadel/client";
import {
CredentialsCheckError,
CredentialsCheckErrorSchema,
ErrorDetail,
} from "@zitadel/proto/zitadel/message_pb";
import {
Challenges,
RequestChallenges,
} from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { CredentialsCheckError, CredentialsCheckErrorSchema, ErrorDetail } from "@zitadel/proto/zitadel/message_pb";
import { Challenges, RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
@@ -82,15 +75,9 @@ export async function createSessionAndUpdateCookie(command: {
const sessionCookie: CustomCookieData = {
id: createdSession.sessionId,
token: createdSession.sessionToken,
creationTs: response.session.creationDate
? `${timestampMs(response.session.creationDate)}`
: "",
expirationTs: response.session.expirationDate
? `${timestampMs(response.session.expirationDate)}`
: "",
changeTs: response.session.changeDate
? `${timestampMs(response.session.changeDate)}`
: "",
creationTs: response.session.creationDate ? `${timestampMs(response.session.creationDate)}` : "",
expirationTs: response.session.expirationDate ? `${timestampMs(response.session.expirationDate)}` : "",
changeTs: response.session.changeDate ? `${timestampMs(response.session.changeDate)}` : "",
loginName: response.session.factors.user.loginName ?? "",
};
@@ -99,16 +86,13 @@ export async function createSessionAndUpdateCookie(command: {
}
if (response.session.factors.user.organizationId) {
sessionCookie.organization =
response.session.factors.user.organizationId;
sessionCookie.organization = response.session.factors.user.organizationId;
}
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled
? "none"
: true;
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
await addSessionToCookie({ session: sessionCookie, sameSite });
await addSessionToCookie({ session: sessionCookie, iFrameEnabled });
return response.session as Session;
} else {
@@ -140,9 +124,7 @@ export async function createSessionForIdpAndUpdateCookie({
let sessionLifetime = lifetime;
if (!sessionLifetime) {
console.warn(
"No IDP session lifetime provided, using default of 24 hours.",
);
console.warn("No IDP session lifetime provided, using default of 24 hours.");
sessionLifetime = {
seconds: BigInt(24 * 60 * 60), // 24 hours
@@ -183,12 +165,8 @@ export async function createSessionForIdpAndUpdateCookie({
const sessionCookie: CustomCookieData = {
id: createdSession.sessionId,
token: createdSession.sessionToken,
creationTs: session.creationDate
? `${timestampMs(session.creationDate)}`
: "",
expirationTs: session.expirationDate
? `${timestampMs(session.expirationDate)}`
: "",
creationTs: session.creationDate ? `${timestampMs(session.creationDate)}` : "",
expirationTs: session.expirationDate ? `${timestampMs(session.expirationDate)}` : "",
changeTs: session.changeDate ? `${timestampMs(session.changeDate)}` : "",
loginName: session.factors.user.loginName ?? "",
organization: session.factors.user.organizationId ?? "",
@@ -203,9 +181,9 @@ export async function createSessionForIdpAndUpdateCookie({
}
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true;
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
return addSessionToCookie({ session: sessionCookie, sameSite }).then(() => {
return addSessionToCookie({ session: sessionCookie, iFrameEnabled }).then(() => {
return session as Session;
});
}
@@ -240,9 +218,7 @@ export async function setSessionAndUpdateCookie(command: {
creationTs: command.recentCookie.creationTs,
expirationTs: command.recentCookie.expirationTs,
// just overwrite the changeDate with the new one
changeTs: updatedSession.details?.changeDate
? `${timestampMs(updatedSession.details.changeDate)}`
: "",
changeTs: updatedSession.details?.changeDate ? `${timestampMs(updatedSession.details.changeDate)}` : "",
loginName: command.recentCookie.loginName,
organization: command.recentCookie.organization,
};
@@ -256,10 +232,7 @@ export async function setSessionAndUpdateCookie(command: {
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
}).then(async (response) => {
if (
!response?.session ||
!response.session.factors?.user?.loginName
) {
if (!response?.session || !response.session.factors?.user?.loginName) {
throw "could not get session or session does not have loginName";
}
@@ -270,9 +243,7 @@ export async function setSessionAndUpdateCookie(command: {
creationTs: sessionCookie.creationTs,
expirationTs: sessionCookie.expirationTs,
// just overwrite the changeDate with the new one
changeTs: updatedSession.details?.changeDate
? `${timestampMs(updatedSession.details.changeDate)}`
: "",
changeTs: updatedSession.details?.changeDate ? `${timestampMs(updatedSession.details.changeDate)}` : "",
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",
};
@@ -282,14 +253,12 @@ export async function setSessionAndUpdateCookie(command: {
}
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled
? "none"
: true;
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
return updateSessionCookie({
id: sessionCookie.id,
session: newCookie,
sameSite,
iFrameEnabled,
}).then(() => {
return { challenges: updatedSession.challenges, ...session };
});

View File

@@ -69,10 +69,7 @@ export async function skipMFAAndContinueWithNextUrl({
}
}
export async function continueWithSession({
requestId,
...session
}: Session & { requestId?: string }) {
export async function continueWithSession({ requestId, ...session }: Session & { requestId?: string }) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
@@ -116,8 +113,7 @@ export type UpdateSessionCommand = {
};
export async function updateSession(options: UpdateSessionCommand) {
let { loginName, sessionId, organization, checks, requestId, challenges } =
options;
let { loginName, sessionId, organization, checks, requestId, challenges } = options;
const recentSession = sessionId
? await getSessionCookieById({ sessionId })
: loginName
@@ -138,12 +134,7 @@ export async function updateSession(options: UpdateSessionCommand) {
return { error: "Could not get host" };
}
if (
host &&
challenges &&
challenges.webAuthN &&
!challenges.webAuthN.domain
) {
if (host && challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
const [hostname] = host.split(":");
challenges.webAuthN.domain = hostname;
@@ -219,11 +210,11 @@ export async function clearSession(options: ClearSessionOptions) {
});
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true;
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
if (!deleteResponse) {
throw new Error("Could not delete session");
}
return removeSessionFromCookie({ session: sessionCookie, sameSite });
return removeSessionFromCookie({ session: sessionCookie, iFrameEnabled });
}