From fdafb95a63dc5df3140709701335fbebe182f18d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 15 Jun 2026 16:33:54 +0200 Subject: [PATCH] fix(login): load custom font from branding settings and allow in CSP (#12279) Closes #11200 # Which Problems Are Solved Custom fonts uploaded via the branding/label policy were not supported. # How the Problems Are Solved - The login now correctly applies the branding settings, based on the organization context - Use the absolute `fontUrl` from the branding API directly in the `@font-face` `src`, matching how logo and icon assets are already loaded via absolute URLs. - Add the Zitadel service URL to the `font-src` CSP directive so the browser permits loading the cross-origin font. # Additional Changes - Updated CSP tests to reflect the new `font-src` behavior. --- apps/login/src/components/theme-wrapper.tsx | 60 +++++++++++++++++++++ apps/login/src/lib/csp.test.ts | 5 +- apps/login/src/lib/csp.ts | 1 + apps/login/src/proxy.ts | 2 +- apps/login/src/styles/globals.scss | 2 +- 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/login/src/components/theme-wrapper.tsx b/apps/login/src/components/theme-wrapper.tsx index 87c54e7ed..e601cae98 100644 --- a/apps/login/src/components/theme-wrapper.tsx +++ b/apps/login/src/components/theme-wrapper.tsx @@ -18,6 +18,66 @@ export const ThemeWrapper = ({ children, branding }: Props) => { setTheme(document, branding); }, [branding]); + // Apply custom font from branding settings before paint to avoid FOUC. + // When a custom font is uploaded via the label/branding policy, fontUrl + // contains a fully-resolved URL to the font file served by the assets API. + // We inject a @font-face rule and set a CSS custom property so the entire + // login UI picks up the custom font with the existing font as fallback. + useLayoutEffect(() => { + const STYLE_ID = "zitadel-custom-font"; + + if (branding?.fontUrl) { + let fontSrc: string; + try { + fontSrc = new URL(branding.fontUrl).href; + } catch { + // Malformed URL — skip custom font + return; + } + + let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null; + if (!styleEl) { + styleEl = document.createElement("style"); + styleEl.id = STYLE_ID; + document.head.appendChild(styleEl); + } + // Capture the current font-family (Lato from next/font) before overriding, + // so it serves as fallback if the custom font fails to load. + const existingFont = getComputedStyle(document.documentElement).fontFamily || "sans-serif"; + const fontStack = `'ZitadelCustomFont', ${existingFont}`; + + styleEl.textContent = ` + @font-face { + font-family: 'ZitadelCustomFont'; + font-style: normal; + font-display: swap; + src: url('${fontSrc}'); + } + `; + + document.documentElement.style.setProperty("--zitadel-font-family", fontStack); + // Inline style overrides the class-based Lato from next/font + document.documentElement.style.setProperty("font-family", fontStack); + } else { + // No custom font — remove injected style and let Lato class take over + const existing = document.getElementById(STYLE_ID); + if (existing) { + existing.remove(); + } + document.documentElement.style.removeProperty("--zitadel-font-family"); + document.documentElement.style.removeProperty("font-family"); + } + + return () => { + const existing = document.getElementById(STYLE_ID); + if (existing) { + existing.remove(); + } + document.documentElement.style.removeProperty("--zitadel-font-family"); + document.documentElement.style.removeProperty("font-family"); + }; + }, [branding?.fontUrl]); + // Publish themeMode to the module-level store so ThemeSwitch can read it useEffect(() => { setThemeMode(branding?.themeMode ?? ThemeMode.UNSPECIFIED); diff --git a/apps/login/src/lib/csp.test.ts b/apps/login/src/lib/csp.test.ts index a0a6f6151..43553bb61 100644 --- a/apps/login/src/lib/csp.test.ts +++ b/apps/login/src/lib/csp.test.ts @@ -15,11 +15,11 @@ describe("buildCSP", () => { expect(csp).toContain("frame-ancestors 'none'"); }); - test("adds serviceUrl to img-src only", () => { + test("adds serviceUrl to img-src and font-src", () => { const csp = buildCSP({ serviceUrl: "https://my-instance.zitadel.cloud" }); expect(csp).toContain("img-src 'self' https://my-instance.zitadel.cloud"); - expect(csp).toMatch(/font-src 'self'(?:;| |$)/); + expect(csp).toContain("font-src 'self' https://my-instance.zitadel.cloud"); }); test("keeps frame-ancestors as 'none' when iframeOrigins is empty", () => { @@ -44,6 +44,7 @@ describe("buildCSP", () => { }); expect(csp).toContain("img-src 'self' https://zitadel.mycompany.com"); + expect(csp).toContain("font-src 'self' https://zitadel.mycompany.com"); expect(csp).toContain("frame-ancestors https://portal.mycompany.com"); expect(csp).not.toContain("frame-ancestors 'none'"); }); diff --git a/apps/login/src/lib/csp.ts b/apps/login/src/lib/csp.ts index 8bfa2ffa6..439eaf86c 100644 --- a/apps/login/src/lib/csp.ts +++ b/apps/login/src/lib/csp.ts @@ -19,6 +19,7 @@ export function buildCSP(options: CSPOptions = {}): string { if (options.serviceUrl) { directives["img-src"] = [...directives["img-src"], options.serviceUrl]; + directives["font-src"] = [...directives["font-src"], options.serviceUrl]; } if (options.iframeOrigins && options.iframeOrigins.length > 0) { diff --git a/apps/login/src/proxy.ts b/apps/login/src/proxy.ts index 78892f78c..6fa05763c 100644 --- a/apps/login/src/proxy.ts +++ b/apps/login/src/proxy.ts @@ -60,7 +60,7 @@ export async function proxy(request: NextRequest) { } // Only proxy paths need to be rewritten to the ZITADEL backend - const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"]; + const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/", "/assets/"]; const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix)); if (!isMatched) { diff --git a/apps/login/src/styles/globals.scss b/apps/login/src/styles/globals.scss index a54e94f2c..287c44ab3 100755 --- a/apps/login/src/styles/globals.scss +++ b/apps/login/src/styles/globals.scss @@ -1,5 +1,5 @@ // https://github.com/tailwindlabs/tailwindcss/discussions/16173 -@import "tailwindcss"; +@use "tailwindcss"; @config "../../tailwind.config.mjs"; @layer base {