feat: add Keycloak authentication
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseRuntimeConfig } from "@/shared/config/env";
|
||||
import {
|
||||
initializeAuthentication,
|
||||
toAuthenticatedUser,
|
||||
toKeycloakConfig
|
||||
} from "./keycloak-auth";
|
||||
|
||||
const requiredConfig = parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/auth/realms/tjwater",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
});
|
||||
|
||||
describe("Keycloak authentication", () => {
|
||||
it("derives the Keycloak server and Realm from the issuer", () => {
|
||||
expect(toKeycloakConfig(requiredConfig.TJWATER_KEYCLOAK_ISSUER, "next-tjwater")).toEqual({
|
||||
url: "https://auth.example.test/auth",
|
||||
realm: "tjwater",
|
||||
clientId: "next-tjwater"
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes the public SPA flow and exposes the refreshed token", async () => {
|
||||
const client = {
|
||||
token: "access-token",
|
||||
idTokenParsed: {
|
||||
sub: "user-1",
|
||||
name: "张调度",
|
||||
email: "operator@example.test"
|
||||
},
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
updateToken: vi.fn().mockResolvedValue(false)
|
||||
};
|
||||
|
||||
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||
|
||||
expect(client.init).toHaveBeenCalledWith({
|
||||
onLoad: "login-required",
|
||||
flow: "standard",
|
||||
pkceMethod: "S256"
|
||||
});
|
||||
expect(authentication.user).toEqual({
|
||||
name: "张调度",
|
||||
role: "operator@example.test"
|
||||
});
|
||||
await expect(authentication.getAccessToken()).resolves.toBe("access-token");
|
||||
expect(client.updateToken).toHaveBeenCalledWith(30);
|
||||
|
||||
await authentication.logout?.();
|
||||
expect(client.logout).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||
});
|
||||
|
||||
it("uses stable user claim fallbacks", () => {
|
||||
expect(toAuthenticatedUser({ sub: "user-2", preferred_username: "dispatcher" })).toEqual({
|
||||
name: "dispatcher",
|
||||
role: "统一认证用户"
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to Keycloak instead of using a token after refresh failure", async () => {
|
||||
const refreshError = new Error("refresh failed");
|
||||
const client = {
|
||||
token: "expired-token",
|
||||
tokenParsed: { sub: "user-3" },
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
updateToken: vi.fn().mockRejectedValue(refreshError)
|
||||
};
|
||||
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||
|
||||
await expect(authentication.getAccessToken()).rejects.toBe(refreshError);
|
||||
expect(client.login).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||
});
|
||||
|
||||
it("keeps authentication inert when explicitly disabled", async () => {
|
||||
const createClient = vi.fn();
|
||||
const authentication = await initializeAuthentication(
|
||||
parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" }),
|
||||
createClient
|
||||
);
|
||||
|
||||
expect(authentication.enabled).toBe(false);
|
||||
await expect(authentication.getAccessToken()).resolves.toBeNull();
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import Keycloak, {
|
||||
type KeycloakConfig,
|
||||
type KeycloakInitOptions,
|
||||
type KeycloakLoginOptions,
|
||||
type KeycloakLogoutOptions,
|
||||
type KeycloakTokenParsed
|
||||
} from "keycloak-js";
|
||||
import type { RuntimeConfig } from "@/shared/config/env";
|
||||
|
||||
const TOKEN_MIN_VALIDITY_SECONDS = 30;
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type AccessTokenProvider = () => Promise<string | null>;
|
||||
|
||||
export type Authentication = {
|
||||
enabled: boolean;
|
||||
user: AuthenticatedUser | null;
|
||||
getAccessToken: AccessTokenProvider;
|
||||
logout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type KeycloakClient = {
|
||||
token?: string;
|
||||
tokenParsed?: KeycloakTokenParsed;
|
||||
idTokenParsed?: KeycloakTokenParsed;
|
||||
onTokenExpired?: () => void;
|
||||
init: (options: KeycloakInitOptions) => Promise<boolean>;
|
||||
login: (options?: KeycloakLoginOptions) => Promise<void>;
|
||||
logout: (options?: KeycloakLogoutOptions) => Promise<void>;
|
||||
updateToken: (minValidity: number) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient;
|
||||
|
||||
export async function initializeAuthentication(
|
||||
config: RuntimeConfig,
|
||||
createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig)
|
||||
): Promise<Authentication> {
|
||||
if (config.TJWATER_AUTH_MODE === "disabled") {
|
||||
return {
|
||||
enabled: false,
|
||||
user: null,
|
||||
getAccessToken: async () => null
|
||||
};
|
||||
}
|
||||
|
||||
const keycloak = createClient(
|
||||
toKeycloakConfig(
|
||||
config.TJWATER_KEYCLOAK_ISSUER,
|
||||
config.TJWATER_KEYCLOAK_CLIENT_ID
|
||||
)
|
||||
);
|
||||
const redirectUri = `${window.location.origin}/`;
|
||||
const login = () => keycloak.login({ redirectUri });
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: "login-required",
|
||||
flow: "standard",
|
||||
pkceMethod: "S256"
|
||||
});
|
||||
|
||||
if (!authenticated) {
|
||||
await login();
|
||||
throw new Error("Keycloak authentication was not completed");
|
||||
}
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
try {
|
||||
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
|
||||
} catch (error) {
|
||||
await login();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
keycloak.onTokenExpired = () => {
|
||||
void refreshAccessToken().catch(() => undefined);
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
|
||||
getAccessToken: async () => {
|
||||
await refreshAccessToken();
|
||||
if (!keycloak.token) {
|
||||
throw new Error("Keycloak access token is unavailable");
|
||||
}
|
||||
return keycloak.token;
|
||||
},
|
||||
logout: () => keycloak.logout({ redirectUri })
|
||||
};
|
||||
}
|
||||
|
||||
export function toKeycloakConfig(issuer: string, clientId: string): KeycloakConfig {
|
||||
const url = new URL(issuer);
|
||||
const realmMarker = "/realms/";
|
||||
const markerIndex = url.pathname.lastIndexOf(realmMarker);
|
||||
const realm = decodeURIComponent(url.pathname.slice(markerIndex + realmMarker.length));
|
||||
const serverPath = url.pathname.slice(0, markerIndex).replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
url: `${url.origin}${serverPath}`,
|
||||
realm,
|
||||
clientId
|
||||
};
|
||||
}
|
||||
|
||||
export function toAuthenticatedUser(token: KeycloakTokenParsed | undefined): AuthenticatedUser {
|
||||
const name = readClaim(token, "name") ?? readClaim(token, "preferred_username") ?? readClaim(token, "sub") ?? "已认证用户";
|
||||
return {
|
||||
name,
|
||||
role: readClaim(token, "email") ?? "统一认证用户"
|
||||
};
|
||||
}
|
||||
|
||||
function readClaim(token: KeycloakTokenParsed | undefined, key: string) {
|
||||
const value = token?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ describe("runtime frontend configuration", () => {
|
||||
it("uses container-provided values without relying on Vite build variables", () => {
|
||||
expect(
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
@@ -14,6 +17,9 @@ describe("runtime frontend configuration", () => {
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
})
|
||||
).toEqual({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
@@ -24,12 +30,18 @@ describe("runtime frontend configuration", () => {
|
||||
});
|
||||
|
||||
it("applies typed defaults for optional feature flags", () => {
|
||||
expect(parseRuntimeConfig({})).toMatchObject({
|
||||
expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||
TJWATER_ENABLE_DEV_PANEL: false,
|
||||
TJWATER_ENABLE_MSW: false
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a Realm issuer when authentication is enabled", () => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "required" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid runtime URLs before the application starts", () => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
||||
});
|
||||
@@ -39,6 +51,16 @@ describe("runtime frontend configuration", () => {
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||
expect(() => parseRuntimeConfig({ [key]: value })).toThrow();
|
||||
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a Keycloak URL that does not identify a Realm", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,14 +22,53 @@ const browserHttpUrl = z
|
||||
}
|
||||
});
|
||||
|
||||
const runtimeConfigSchema = z.object({
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||
});
|
||||
const keycloakIssuer = z
|
||||
.string()
|
||||
.trim()
|
||||
.default("")
|
||||
.superRefine((value, context) => {
|
||||
if (!value) return;
|
||||
const parsed = browserHttpUrl.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须是安全的 HTTP(S) 地址" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须指向具体 Realm" });
|
||||
}
|
||||
})
|
||||
.transform((value) => value.replace(/\/+$/, ""));
|
||||
|
||||
const runtimeConfigSchema = z
|
||||
.object({
|
||||
TJWATER_AUTH_MODE: z.enum(["required", "disabled"]).default("required"),
|
||||
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||
})
|
||||
.superRefine((config, context) => {
|
||||
if (config.TJWATER_AUTH_MODE !== "required") return;
|
||||
if (!config.TJWATER_KEYCLOAK_ISSUER) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["TJWATER_KEYCLOAK_ISSUER"],
|
||||
message: "启用认证时必须配置 Keycloak issuer"
|
||||
});
|
||||
}
|
||||
if (!config.TJWATER_KEYCLOAK_CLIENT_ID) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["TJWATER_KEYCLOAK_CLIENT_ID"],
|
||||
message: "启用认证时必须配置 Keycloak client ID"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
var __TJWATER_CONFIG__: unknown;
|
||||
|
||||
Reference in New Issue
Block a user