From fb27c6d212127feed73dbaea5470e338b26d2951 Mon Sep 17 00:00:00 2001 From: TomRoyls Date: Wed, 4 Mar 2026 18:17:16 +0100 Subject: [PATCH] fix: ui_locales being ignored in v2 login ui (#11617) # Which Problems Are Solved * v2 login ui was ignoring oicd `ui_locales` making it impossible to sync the login ui locale with the apps ui # How the Problems Are Solved - Read `uiLocales` from `authRequest` - check all listed if valid in ui - take the first valid one - set language cookie (if a valid locale is pressent in uiLocales) # Additional Changes - tests # Additional Context --------- Co-authored-by: Florian Forster Co-authored-by: Max Peintner --- apps/login/.env | 5 + apps/login/src/lib/auth-utils.test.ts | 53 ++++- apps/login/src/lib/auth-utils.ts | 42 ++++ apps/login/src/lib/cookies.test.ts | 28 +++ apps/login/src/lib/cookies.ts | 6 + apps/login/src/lib/i18n.ts | 5 + .../src/lib/server/flow-initiation.test.ts | 181 ++++++++++++++++++ apps/login/src/lib/server/flow-initiation.ts | 11 ++ 8 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 apps/login/src/lib/server/flow-initiation.test.ts diff --git a/apps/login/.env b/apps/login/.env index 09ad84ac4..c8b7259d0 100644 --- a/apps/login/.env +++ b/apps/login/.env @@ -2,3 +2,8 @@ NEXT_PUBLIC_BASE_PATH=/ui/v2/login EMAIL_VERIFICATION=false ZITADEL_API_URL=http://localhost:8080 ZITADEL_SERVICE_USER_TOKEN_FILE=../../login-client.pat + +# Whether ui_locales from OAuth requests should always override the language cookie. +# When set to "true", the language from ui_locales will always be used. +# When unset or set to "false", ui_locales only sets the language if no cookie exists (preserves user's manual selection). +# ZITADEL_UI_LOCALES_OVERRIDE_COOKIE=false diff --git a/apps/login/src/lib/auth-utils.test.ts b/apps/login/src/lib/auth-utils.test.ts index 85f51fae9..654baa057 100644 --- a/apps/login/src/lib/auth-utils.test.ts +++ b/apps/login/src/lib/auth-utils.test.ts @@ -1,7 +1,58 @@ import { describe, it, expect } from "vitest"; -import { validateAuthRequest, isRSCRequest } from "./auth-utils"; +import { validateAuthRequest, isRSCRequest, isValidLanguage, getValidLocaleFromUILocales } from "./auth-utils"; describe("auth-utils", () => { + describe("isValidLanguage", () => { + it("should return true for valid language codes", () => { + expect(isValidLanguage("en")).toBe(true); + expect(isValidLanguage("de")).toBe(true); + expect(isValidLanguage("fr")).toBe(true); + expect(isValidLanguage("zh")).toBe(true); + }); + + it("should return false for invalid language codes", () => { + expect(isValidLanguage("invalid")).toBe(false); + expect(isValidLanguage("xx")).toBe(false); + expect(isValidLanguage("")).toBe(false); + }); + + it("should be case-insensitive", () => { + expect(isValidLanguage("EN")).toBe(true); + expect(isValidLanguage("De")).toBe(true); + }); + }); + + describe("getValidLocaleFromUILocales", () => { + it("should return null for undefined or empty array", () => { + expect(getValidLocaleFromUILocales(undefined)).toBeNull(); + expect(getValidLocaleFromUILocales([])).toBeNull(); + }); + + it("should extract valid language from simple locale codes", () => { + expect(getValidLocaleFromUILocales(["de"])).toBe("de"); + expect(getValidLocaleFromUILocales(["en", "de"])).toBe("en"); + }); + + it("should extract language code from language tags", () => { + expect(getValidLocaleFromUILocales(["de-CH"])).toBe("de"); + expect(getValidLocaleFromUILocales(["en-US"])).toBe("en"); + expect(getValidLocaleFromUILocales(["zh-CN"])).toBe("zh"); + }); + + it("should return first valid language when multiple provided", () => { + expect(getValidLocaleFromUILocales(["xx-XX", "de", "en"])).toBe("de"); + }); + + it("should return null when no valid languages found", () => { + expect(getValidLocaleFromUILocales(["xx", "yy", "zz"])).toBeNull(); + }); + + it("should handle case-insensitive matching", () => { + expect(getValidLocaleFromUILocales(["DE"])).toBe("de"); + expect(getValidLocaleFromUILocales(["En-US"])).toBe("en"); + }); + }); + describe("validateAuthRequest", () => { it("should return null when no auth parameters are present", () => { const searchParams = new URLSearchParams(); diff --git a/apps/login/src/lib/auth-utils.ts b/apps/login/src/lib/auth-utils.ts index b49197e22..32ebb278c 100644 --- a/apps/login/src/lib/auth-utils.ts +++ b/apps/login/src/lib/auth-utils.ts @@ -1,7 +1,49 @@ +import { LANGS } from "@/lib/i18n"; + /** * Authentication utility functions that don't require server actions */ +/** + * Check if a language code is valid (supported by the login UI) + */ +export function isValidLanguage(code: string): boolean { + const normalized = code.trim().toLowerCase(); + return LANGS.some((lang) => lang.code === normalized); +} + +/** + * Extract a valid language code from uiLocales array. + * Returns the first valid language code (normalized to lowercase), or null if none found. + */ +export function getValidLocaleFromUILocales(uiLocales: string[] | undefined): string | null { + if (!uiLocales || uiLocales.length === 0) { + return null; + } + + for (const locale of uiLocales) { + const normalized = locale.trim().toLowerCase(); + + // Check if the full locale is a valid language code (e.g., "de", "EN") + if (isValidLanguage(normalized)) { + return normalized; + } + + // uiLocales may contain language tags like "en-US" or "de-CH" + // Extract the language code (part before the hyphen) + // Note: this strips any regional specifier + // e.g., de-CH and de-AT both become just de + // zh-Hans-CN (Simplified) and zh-Hant-TW (Traditional) both become zh + // As of time of writing, this is expected behaviour, since there is only one translation for all languages + const languageCode = normalized.split("-")[0]; + if (isValidLanguage(languageCode)) { + return languageCode; + } + } + + return null; +} + /** * Validate authentication request parameters */ diff --git a/apps/login/src/lib/cookies.test.ts b/apps/login/src/lib/cookies.test.ts index faccfce41..533daa9be 100644 --- a/apps/login/src/lib/cookies.test.ts +++ b/apps/login/src/lib/cookies.test.ts @@ -11,6 +11,7 @@ import { getAllSessions, getMostRecentCookieWithLoginname, setLanguageCookie, + getLanguageCookie, } from "./cookies"; import { cookies } from "next/headers"; @@ -67,6 +68,33 @@ describe("cookies", () => { }); }); + describe("getLanguageCookie", () => { + it("should return the language cookie value when set", async () => { + mockCookies.get.mockReturnValue({ value: "de" }); + + const result = await getLanguageCookie(); + + expect(result).toBe("de"); + expect(mockCookies.get).toHaveBeenCalledWith("NEXT_LOCALE"); + }); + + it("should return undefined when no language cookie exists", async () => { + mockCookies.get.mockReturnValue(undefined); + + const result = await getLanguageCookie(); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when cookie has no value", async () => { + mockCookies.get.mockReturnValue({ value: undefined }); + + const result = await getLanguageCookie(); + + expect(result).toBeUndefined(); + }); + }); + describe("addSessionToCookie", () => { const mockSession: Cookie = { id: "session-1", diff --git a/apps/login/src/lib/cookies.ts b/apps/login/src/lib/cookies.ts index 2454eefc9..3e97b5690 100644 --- a/apps/login/src/lib/cookies.ts +++ b/apps/login/src/lib/cookies.ts @@ -58,6 +58,12 @@ export async function setLanguageCookie(language: string) { }); } +export async function getLanguageCookie(): Promise { + const cookiesList = await cookies(); + const languageCookie = cookiesList.get(LANGUAGE_COOKIE_NAME); + return languageCookie?.value; +} + export async function addSessionToCookie({ session, cleanup, diff --git a/apps/login/src/lib/i18n.ts b/apps/login/src/lib/i18n.ts index d54d133d1..660632425 100644 --- a/apps/login/src/lib/i18n.ts +++ b/apps/login/src/lib/i18n.ts @@ -61,6 +61,11 @@ export const LANGS: Lang[] = [ export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE"; export const LANGUAGE_HEADER_NAME = "accept-language"; + +export function shouldUILocalesOverrideCookie(): boolean { + return process.env.ZITADEL_UI_LOCALES_OVERRIDE_COOKIE === "true"; +} + export function getLanguage(code: string): Lang { const lang = LANGS.find((l) => l.code === code); if (lang) { diff --git a/apps/login/src/lib/server/flow-initiation.test.ts b/apps/login/src/lib/server/flow-initiation.test.ts new file mode 100644 index 000000000..10b9a6050 --- /dev/null +++ b/apps/login/src/lib/server/flow-initiation.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { handleOIDCFlowInitiation, FlowInitiationParams } from "./flow-initiation"; + +vi.mock("@/lib/cookies", () => ({ + getLanguageCookie: vi.fn(), + setLanguageCookie: vi.fn(), +})); + +vi.mock("@/lib/auth-utils", () => ({ + getValidLocaleFromUILocales: vi.fn(), +})); + +vi.mock("@/lib/idp", () => ({ + idpTypeToSlug: vi.fn(), +})); + +vi.mock("@/lib/server/loginname", () => ({ + sendLoginname: vi.fn(), +})); + +vi.mock("@/lib/service-url", () => ({ + constructUrl: vi.fn(), +})); + +vi.mock("@/lib/session", () => ({ + findValidSession: vi.fn(), +})); + +vi.mock("@/lib/zitadel", () => ({ + createCallback: vi.fn(), + createResponse: vi.fn(), + getActiveIdentityProviders: vi.fn(), + getAuthRequest: vi.fn(), + getOrgsByDomain: vi.fn(), + getSAMLRequest: vi.fn(), + getSecuritySettings: vi.fn(), + startIdentityProviderFlow: vi.fn(), +})); + +vi.mock("@zitadel/client", () => ({ + create: vi.fn(), +})); + +vi.mock("escape-html", () => ({ + default: (s: string) => s, +})); + +function makeRequest(url = "https://example.com/login?requestId=oidc_abc123"): NextRequest { + return new NextRequest(url); +} + +function makeBaseParams(overrides?: Partial): FlowInitiationParams { + return { + serviceConfig: { baseUrl: "https://api.example.com" } as any, + requestId: "oidc_abc123", + sessions: [], + sessionCookies: [], + request: makeRequest(), + ...overrides, + }; +} + +describe("handleOIDCFlowInitiation — locale / cookie handling", () => { + let mockGetLanguageCookie: ReturnType; + let mockSetLanguageCookie: ReturnType; + let mockGetValidLocaleFromUILocales: ReturnType; + let mockGetAuthRequest: ReturnType; + let mockConstructUrl: ReturnType; + let mockFindValidSession: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + + const cookies = await import("@/lib/cookies"); + const authUtils = await import("@/lib/auth-utils"); + const zitadel = await import("@/lib/zitadel"); + const serviceUrl = await import("@/lib/service-url"); + const session = await import("@/lib/session"); + + mockGetLanguageCookie = vi.mocked(cookies.getLanguageCookie); + mockSetLanguageCookie = vi.mocked(cookies.setLanguageCookie); + mockGetValidLocaleFromUILocales = vi.mocked(authUtils.getValidLocaleFromUILocales); + mockGetAuthRequest = vi.mocked(zitadel.getAuthRequest); + mockConstructUrl = vi.mocked(serviceUrl.constructUrl); + mockFindValidSession = vi.mocked(session.findValidSession); + + // Default auth request: no prompts, no special scopes + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: undefined, + }, + }); + + // constructUrl returns a real URL object so redirect paths resolve correctly + mockConstructUrl.mockImplementation((_req: any, path: string) => { + return new URL(`https://example.com${path}`); + }); + + mockFindValidSession.mockResolvedValue(null); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("when uiLocales yields no valid locale", () => { + test("should not read or set the language cookie", async () => { + mockGetValidLocaleFromUILocales.mockReturnValue(null); + + await handleOIDCFlowInitiation(makeBaseParams()); + + expect(mockGetLanguageCookie).not.toHaveBeenCalled(); + expect(mockSetLanguageCookie).not.toHaveBeenCalled(); + }); + }); + + describe("when uiLocales yields a valid locale and ZITADEL_UI_LOCALES_OVERRIDE_COOKIE is unset (default)", () => { + beforeEach(() => { + mockGetValidLocaleFromUILocales.mockReturnValue("de"); + }); + + test("should set cookie when no existing language cookie is present", async () => { + mockGetLanguageCookie.mockResolvedValue(undefined); + + await handleOIDCFlowInitiation(makeBaseParams()); + + expect(mockGetLanguageCookie).toHaveBeenCalledOnce(); + expect(mockSetLanguageCookie).toHaveBeenCalledWith("de"); + }); + + test("should NOT overwrite an existing language cookie", async () => { + mockGetLanguageCookie.mockResolvedValue("fr"); + + await handleOIDCFlowInitiation(makeBaseParams()); + + expect(mockGetLanguageCookie).toHaveBeenCalledOnce(); + expect(mockSetLanguageCookie).not.toHaveBeenCalled(); + }); + }); + + describe("when uiLocales yields a valid locale and ZITADEL_UI_LOCALES_OVERRIDE_COOKIE=true", () => { + beforeEach(() => { + vi.stubEnv("ZITADEL_UI_LOCALES_OVERRIDE_COOKIE", "true"); + mockGetValidLocaleFromUILocales.mockReturnValue("ja"); + }); + + test("should set cookie even when an existing language cookie is already set", async () => { + mockGetLanguageCookie.mockResolvedValue("fr"); + + await handleOIDCFlowInitiation(makeBaseParams()); + + expect(mockSetLanguageCookie).toHaveBeenCalledWith("ja"); + }); + + test("should set cookie when no existing cookie is present", async () => { + mockGetLanguageCookie.mockResolvedValue(undefined); + + await handleOIDCFlowInitiation(makeBaseParams()); + + expect(mockSetLanguageCookie).toHaveBeenCalledWith("ja"); + }); + }); + + describe("when ZITADEL_UI_LOCALES_OVERRIDE_COOKIE is explicitly 'false'", () => { + test("should preserve an existing cookie", async () => { + vi.stubEnv("ZITADEL_UI_LOCALES_OVERRIDE_COOKIE", "false"); + mockGetValidLocaleFromUILocales.mockReturnValue("es"); + mockGetLanguageCookie.mockResolvedValue("it"); + + await handleOIDCFlowInitiation(makeBaseParams()); + + expect(mockSetLanguageCookie).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 848d13572..39072d0d5 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -1,3 +1,6 @@ +import { getValidLocaleFromUILocales } from "@/lib/auth-utils"; +import { shouldUILocalesOverrideCookie } from "@/lib/i18n"; +import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies"; import { idpTypeToSlug } from "@/lib/idp"; import { createLogger } from "@/lib/logger"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; @@ -88,6 +91,14 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr const { authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") }); + const locale = getValidLocaleFromUILocales(authRequest?.uiLocales); + if (locale) { + const existingLanguage = await getLanguageCookie(); + if (shouldUILocalesOverrideCookie() || !existingLanguage) { + await setLanguageCookie(locale); + } + } + let organization = ""; let suffix = ""; let idpId = "";