mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-30 02:52:16 +00:00
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:
co-authored by
Florian Forster
Max Peintner
parent
80aaf52aae
commit
fb27c6d212
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user