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 <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
TomRoyls
2026-03-04 09:17:16 -08:00
committed by GitHub
co-authored by Florian Forster Max Peintner
parent 80aaf52aae
commit fb27c6d212
8 changed files with 330 additions and 1 deletions
+5
View File
@@ -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
+52 -1
View File
@@ -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();
+42
View File
@@ -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
*/
+28
View File
@@ -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",
+6
View File
@@ -58,6 +58,12 @@ export async function setLanguageCookie(language: string) {
});
}
export async function getLanguageCookie(): Promise<string | undefined> {
const cookiesList = await cookies();
const languageCookie = cookiesList.get(LANGUAGE_COOKIE_NAME);
return languageCookie?.value;
}
export async function addSessionToCookie<T>({
session,
cleanup,
+5
View File
@@ -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) {
@@ -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>): 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<typeof vi.fn>;
let mockSetLanguageCookie: ReturnType<typeof vi.fn>;
let mockGetValidLocaleFromUILocales: ReturnType<typeof vi.fn>;
let mockGetAuthRequest: ReturnType<typeof vi.fn>;
let mockConstructUrl: ReturnType<typeof vi.fn>;
let mockFindValidSession: ReturnType<typeof vi.fn>;
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();
});
});
});
@@ -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 = "";