diff --git a/apps/login/constants/csp.js b/apps/login/constants/csp.js new file mode 100644 index 0000000000..5cc1e254f3 --- /dev/null +++ b/apps/login/constants/csp.js @@ -0,0 +1,2 @@ +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; diff --git a/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json index 07e9980f9b..3da4ae999f 100644 --- a/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json +++ b/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -6,6 +6,13 @@ "data": {} } }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetSecuritySettings", + "out": { + "data": {} + } + }, { "service": "zitadel.settings.v2.SettingsService", "method": "GetLegalAndSupportSettings", diff --git a/apps/login/next.config.mjs b/apps/login/next.config.mjs index 00fa1e19c4..edf5e54595 100755 --- a/apps/login/next.config.mjs +++ b/apps/login/next.config.mjs @@ -1,4 +1,5 @@ import createNextIntlPlugin from "next-intl/plugin"; +import { DEFAULT_CSP } from "./constants/csp.js"; const withNextIntl = createNextIntlPlugin(); @@ -29,9 +30,9 @@ const secureHeaders = [ // script-src va.vercel-scripts.com for analytics/vercel scripts { key: "Content-Security-Policy", - value: - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;", + value: `${DEFAULT_CSP} frame-ancestors 'none'`, }, + { key: "X-Frame-Options", value: "deny" }, ]; const imageRemotePatterns = [ diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 8240023c2d..8904eff963 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -33,8 +33,8 @@ export default async function Page(props: { const { serviceUrl } = getServiceUrlFromHeaders(_headers); const sessionWithData = sessionId - ? await loadSessionById(serviceUrl, sessionId, organization) - : await loadSessionByLoginname(serviceUrl, loginName, organization); + ? await loadSessionById(sessionId, organization) + : await loadSessionByLoginname(loginName, organization); async function getAuthMethodsAndUser( serviceUrl: string, @@ -67,7 +67,6 @@ export default async function Page(props: { } async function loadSessionByLoginname( - host: string, loginName?: string, organization?: string, ) { @@ -82,11 +81,7 @@ export default async function Page(props: { }); } - async function loadSessionById( - host: string, - sessionId: string, - organization?: string, - ) { + async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index e3834e5a27..fb2f5e5f49 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -12,6 +12,7 @@ import { getAuthRequest, getOrgsByDomain, getSAMLRequest, + getSecuritySettings, listSessions, startIdentityProviderFlow, } from "@/lib/zitadel"; @@ -25,6 +26,7 @@ import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../../../constants/csp"; export const dynamic = "force-dynamic"; export const revalidate = false; @@ -293,17 +295,32 @@ export async function GET(request: NextRequest) { * This means that the user should not be prompted to enter their password again. * Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction **/ + const securitySettings = await getSecuritySettings({ + serviceUrl, + }); + const selectedSession = await findValidSession({ serviceUrl, sessions, authRequest, }); - if (!selectedSession || !selectedSession.id) { - return NextResponse.json( - { error: "No active session found" }, - { status: 400 }, + const noSessionResponse = NextResponse.json( + { error: "No active session found" }, + { status: 400 }, + ); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + noSessionResponse.headers.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, ); + noSessionResponse.headers.delete("X-Frame-Options"); + } + + if (!selectedSession || !selectedSession.id) { + return noSessionResponse; } const cookie = sessionCookies.find( @@ -311,10 +328,7 @@ export async function GET(request: NextRequest) { ); if (!cookie || !cookie.id || !cookie.token) { - return NextResponse.json( - { error: "No active session found" }, - { status: 400 }, - ); + return noSessionResponse; } const session = { @@ -332,7 +346,19 @@ export async function GET(request: NextRequest) { }, }), }); - return NextResponse.redirect(callbackUrl); + + const callbackResponse = NextResponse.redirect(callbackUrl); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + callbackResponse.headers.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + callbackResponse.headers.delete("X-Frame-Options"); + } + + return callbackResponse; } else { // check for loginHint, userId hint and valid sessions let selectedSession = await findValidSession({ diff --git a/apps/login/src/lib/cookies.ts b/apps/login/src/lib/cookies.ts index cf762b904f..76f5580a16 100644 --- a/apps/login/src/lib/cookies.ts +++ b/apps/login/src/lib/cookies.ts @@ -20,7 +20,10 @@ export type Cookie = { type SessionCookie = Cookie & T; -async function setSessionHttpOnlyCookie(sessions: SessionCookie[]) { +async function setSessionHttpOnlyCookie( + sessions: SessionCookie[], + sameSite: boolean | "lax" | "strict" | "none" = true, +) { const cookiesList = await cookies(); return cookiesList.set({ @@ -28,6 +31,7 @@ async function setSessionHttpOnlyCookie(sessions: SessionCookie[]) { value: JSON.stringify(sessions), httpOnly: true, path: "/", + sameSite, }); } @@ -42,10 +46,15 @@ export async function setLanguageCookie(language: string) { }); } -export async function addSessionToCookie( - session: SessionCookie, - cleanup: boolean = false, -): Promise { +export async function addSessionToCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -79,17 +88,23 @@ export async function addSessionToCookie( ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true, ); - return setSessionHttpOnlyCookie(filteredSessions); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); } else { - return setSessionHttpOnlyCookie(currentSessions); + return setSessionHttpOnlyCookie(currentSessions, sameSite); } } -export async function updateSessionCookie( - id: string, - session: SessionCookie, - cleanup: boolean = false, -): Promise { +export async function updateSessionCookie({ + id, + session, + cleanup, + sameSite, +}: { + id: string; + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -108,19 +123,24 @@ export async function updateSessionCookie( ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true, ); - return setSessionHttpOnlyCookie(filteredSessions); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); } else { - return setSessionHttpOnlyCookie(sessions); + return setSessionHttpOnlyCookie(sessions, sameSite); } } else { throw "updateSessionCookie: session id now found"; } } -export async function removeSessionFromCookie( - session: SessionCookie, - cleanup: boolean = false, -): Promise { +export async function removeSessionFromCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { const cookiesList = await cookies(); const stringifiedCookie = cookiesList.get("sessions"); @@ -136,9 +156,9 @@ export async function removeSessionFromCookie( ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now : true, ); - return setSessionHttpOnlyCookie(filteredSessions); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); } else { - return setSessionHttpOnlyCookie(reducedSessions); + return setSessionHttpOnlyCookie(reducedSessions, sameSite); } } diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index d54a4047b1..7cc86e9337 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -4,6 +4,7 @@ import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies"; import { createSessionForUserIdAndIdpIntent, createSessionFromChecks, + getSecuritySettings, getSession, setSession, } from "@/lib/zitadel"; @@ -65,7 +66,7 @@ export async function createSessionAndUpdateCookie(command: { serviceUrl, sessionId: createdSession.sessionId, sessionToken: createdSession.sessionToken, - }).then((response) => { + }).then(async (response) => { if (response?.session && response.session?.factors?.user?.loginName) { const sessionCookie: CustomCookieData = { id: createdSession.sessionId, @@ -91,9 +92,14 @@ export async function createSessionAndUpdateCookie(command: { response.session.factors.user.organizationId; } - return addSessionToCookie(sessionCookie).then(() => { - return response.session as Session; - }); + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + await addSessionToCookie({ session: sessionCookie, sameSite }); + + return response.session as Session; } else { throw "could not get session or session does not have loginName"; } @@ -167,7 +173,10 @@ export async function createSessionForIdpAndUpdateCookie( sessionCookie.organization = session.factors.user.organizationId; } - return addSessionToCookie(sessionCookie).then(() => { + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + return addSessionToCookie({ session: sessionCookie, sameSite }).then(() => { return session as Session; }); } @@ -217,32 +226,44 @@ export async function setSessionAndUpdateCookie( serviceUrl, sessionId: sessionCookie.id, sessionToken: sessionCookie.token, - }).then((response) => { - if (response?.session && response.session.factors?.user?.loginName) { - const { session } = response; - const newCookie: CustomCookieData = { - id: sessionCookie.id, - token: updatedSession.sessionToken, - creationTs: sessionCookie.creationTs, - expirationTs: sessionCookie.expirationTs, - // just overwrite the changeDate with the new one - changeTs: updatedSession.details?.changeDate - ? `${timestampMs(updatedSession.details.changeDate)}` - : "", - loginName: session.factors?.user?.loginName ?? "", - organization: session.factors?.user?.organizationId ?? "", - }; - - if (sessionCookie.requestId) { - newCookie.requestId = sessionCookie.requestId; - } - - return updateSessionCookie(sessionCookie.id, newCookie).then(() => { - return { challenges: updatedSession.challenges, ...session }; - }); - } else { + }).then(async (response) => { + if ( + !response?.session || + !response.session.factors?.user?.loginName + ) { throw "could not get session or session does not have loginName"; } + + const { session } = response; + const newCookie: CustomCookieData = { + id: sessionCookie.id, + token: updatedSession.sessionToken, + creationTs: sessionCookie.creationTs, + expirationTs: sessionCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: session.factors?.user?.loginName ?? "", + organization: session.factors?.user?.organizationId ?? "", + }; + + if (sessionCookie.requestId) { + newCookie.requestId = sessionCookie.requestId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + return updateSessionCookie({ + id: sessionCookie.id, + session: newCookie, + sameSite, + }).then(() => { + return { challenges: updatedSession.challenges, ...session }; + }); }); } else { throw "Session not be set"; diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 66688bf415..3ff3d14017 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -4,6 +4,7 @@ import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; import { deleteSession, getLoginSettings, + getSecuritySettings, humanMFAInitSkipped, listAuthenticationMethodTypes, } from "@/lib/zitadel"; @@ -209,8 +210,11 @@ export async function clearSession(options: ClearSessionOptions) { sessionToken: session.token, }); + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + if (deletedSession) { - return removeSessionFromCookie(session); + return removeSessionFromCookie({ session, sameSite }); } } @@ -230,9 +234,12 @@ export async function cleanupSession({ sessionId }: CleanupSessionCommand) { sessionToken: sessionCookie.token, }); + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + if (!deleteResponse) { throw new Error("Could not delete session"); } - return removeSessionFromCookie(sessionCookie); + return removeSessionFromCookie({ session: sessionCookie, sameSite }); } diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 0511eaaf0d..a5abc0dcb1 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -92,6 +92,21 @@ export async function getLoginSettings({ return useCache ? cacheWrapper(callback) : callback; } +export async function getSecuritySettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getSecuritySettings({}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + export async function getLockoutSettings({ serviceUrl, orgId, diff --git a/apps/login/src/middleware.ts b/apps/login/src/middleware.ts index 8d4080cddf..6184bee182 100644 --- a/apps/login/src/middleware.ts +++ b/apps/login/src/middleware.ts @@ -1,6 +1,8 @@ import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../constants/csp"; import { getServiceUrlFromHeaders } from "./lib/service"; +import { getSecuritySettings } from "./lib/zitadel"; export const config = { matcher: [ @@ -22,6 +24,8 @@ export async function middleware(request: NextRequest) { const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const securitySettings = await getSecuritySettings({ serviceUrl }); + const instanceHost = `${serviceUrl}` .replace("https://", "") .replace("http://", ""); @@ -39,6 +43,15 @@ export async function middleware(request: NextRequest) { responseHeaders.set("Access-Control-Allow-Origin", "*"); responseHeaders.set("Access-Control-Allow-Headers", "*"); + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + responseHeaders.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + responseHeaders.delete("X-Frame-Options"); + } + request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; return NextResponse.rewrite(request.nextUrl, { request: {