From a9cd3ff9c04016e8e94b1273fe1a76cb66838d43 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 11 Sep 2025 17:01:24 +0200 Subject: [PATCH] fix(login): Safari Cookie Issues in Development Mode (#10594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/login/src/lib/cookies.ts | 94 +++++++++++----------------- apps/login/src/lib/server/cookie.ts | 67 ++++++-------------- apps/login/src/lib/server/session.ts | 19 ++---- 3 files changed, 61 insertions(+), 119 deletions(-) diff --git a/apps/login/src/lib/cookies.ts b/apps/login/src/lib/cookies.ts index 71008de24ed..212414a63c4 100644 --- a/apps/login/src/lib/cookies.ts +++ b/apps/login/src/lib/cookies.ts @@ -20,18 +20,26 @@ export type Cookie = { type SessionCookie = Cookie & T; -async function setSessionHttpOnlyCookie( - sessions: SessionCookie[], - sameSite: boolean | "lax" | "strict" | "none" = true, -) { +async function setSessionHttpOnlyCookie(sessions: SessionCookie[], 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({ session, cleanup, - sameSite, + iFrameEnabled, }: { session: SessionCookie; cleanup?: boolean; - sameSite?: boolean | "lax" | "strict" | "none" | undefined; + iFrameEnabled?: boolean; }): Promise { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - let currentSessions: SessionCookie[] = stringifiedCookie?.value - ? JSON.parse(stringifiedCookie?.value) - : []; + let currentSessions: SessionCookie[] = 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({ 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({ id, session, cleanup, - sameSite, + iFrameEnabled, }: { id: string; session: SessionCookie; cleanup?: boolean; - sameSite?: boolean | "lax" | "strict" | "none" | undefined; + iFrameEnabled?: boolean; }): Promise { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - const sessions: SessionCookie[] = stringifiedCookie?.value - ? JSON.parse(stringifiedCookie?.value) - : [session]; + const sessions: SessionCookie[] = stringifiedCookie?.value ? JSON.parse(stringifiedCookie?.value) : [session]; const foundIndex = sessions.findIndex((session) => session.id === id); @@ -120,13 +120,11 @@ export async function updateSessionCookie({ 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: session id now found"; @@ -136,30 +134,26 @@ export async function updateSessionCookie({ export async function removeSessionFromCookie({ session, cleanup, - sameSite, + iFrameEnabled, }: { session: SessionCookie; cleanup?: boolean; - sameSite?: boolean | "lax" | "strict" | "none" | undefined; + iFrameEnabled?: boolean; }) { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); - const sessions: SessionCookie[] = stringifiedCookie?.value - ? JSON.parse(stringifiedCookie?.value) - : [session]; + const sessions: SessionCookie[] = 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({ const sessions: SessionCookie[] = 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({ if (stringifiedCookie?.value) { const sessions: SessionCookie[] = 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({ * @param cleanup when true, removes all expired sessions, default true * @returns Session Cookies */ -export async function getAllSessionCookieIds( - cleanup: boolean = false, -): Promise { +export async function getAllSessionCookieIds(cleanup: boolean = false): Promise { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -253,9 +241,7 @@ export async function getAllSessionCookieIds( 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( * @param cleanup when true, removes all expired sessions, default true * @returns Session Cookies */ -export async function getAllSessions( - cleanup: boolean = false, -): Promise[]> { +export async function getAllSessions(cleanup: boolean = false): Promise[]> { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -283,9 +267,7 @@ export async function getAllSessions( 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; diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 7f87f497316..551d95db5ed 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -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 }; }); diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 957c89ad814..7f8e9e46e9f 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -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 }); }