feat: add Keycloak authentication

This commit is contained in:
2026-08-19 12:11:18 +08:00
parent bdd5eff776
commit d08cf2abc1
27 changed files with 533 additions and 128 deletions
+7 -2
View File
@@ -1,11 +1,16 @@
import { MapToaster } from "@/features/map/core";
import { MapWorkbenchPage } from "@/features/workbench";
import type { Authentication } from "@/shared/auth/keycloak-auth";
import { AppProviders } from "./providers";
export function App() {
export function App({ authentication }: { authentication: Authentication }) {
return (
<AppProviders>
<MapWorkbenchPage />
<MapWorkbenchPage
user={authentication.user ?? undefined}
onLogout={authentication.logout}
getAccessToken={authentication.getAccessToken}
/>
<MapToaster />
</AppProviders>
);
+26
View File
@@ -0,0 +1,26 @@
import { Droplets, RefreshCw } from "lucide-react";
import { Button } from "@/shared/ui/button";
export function AuthStartupScreen({ error }: { error?: boolean }) {
return (
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
<Droplets size={22} aria-hidden="true" />
</span>
<h1 className="mt-4 text-lg font-semibold">
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
</h1>
<p className="mt-2 text-sm leading-6 text-slate-500">
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
</p>
{error ? (
<Button className="mt-5" onClick={() => window.location.reload()}>
<RefreshCw size={15} aria-hidden="true" />
</Button>
) : null}
</section>
</main>
);
}
+17
View File
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
);
});
it("adds the current Keycloak access token without dropping request headers", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
expect(getAccessToken).toHaveBeenCalledOnce();
const init = fetchMock.mock.calls[0]?.[1];
if (!init) throw new Error("Expected Agent request init");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
+63 -79
View File
@@ -1,4 +1,5 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
@@ -112,19 +113,47 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
path,
init,
options.getAccessToken
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
};
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
return requestJson<unknown>("/frontend-action-registry");
},
async submitFrontendActionResult(sessionId, actionId, result) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result)
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
const payload = await requestJson<unknown>("/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
return requestJson<unknown>("/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
return requestJson<unknown>(
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
await requestJson<unknown>("/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
};
}
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
init?: RequestInit,
getAccessToken?: AccessTokenProvider
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -359,6 +337,12 @@ async function fetchWithFallback(
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
const headers = new Headers(headersInit);
headers.set("Authorization", `Bearer ${accessToken}`);
return headers;
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
+1
View File
@@ -6,6 +6,7 @@ export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export type {
AgentApiClient,
AgentApiClientOptions,
AgentChatSessionSummary,
AgentLoadedChatSession,
AgentSessionStreamEvent
@@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
{index + 1}
</span>
<span className="line-clamp-1">{item}</span>
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700"></span>
<StatusBadge tone="info" activity="loading" className="mt-0.5">
</StatusBadge>
</li>
))
) : (
@@ -283,7 +283,8 @@ export function UserMenu({
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
onExportConfig,
onLogout
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -292,6 +293,7 @@ export function UserMenu({
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
@@ -320,7 +322,15 @@ export function UserMenu({
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator />
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<DropdownMenuItem
disabled={!onLogout}
onSelect={onLogout ? () => void onLogout() : undefined}
className={cn(
"px-2 py-2",
!onLogout && "text-slate-400",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
<LogOut size={15} aria-hidden="true" />
退
</DropdownMenuItem>
@@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = {
onRefreshTiles: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
};
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
@@ -72,7 +73,8 @@ export function WorkbenchTopBar({
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
onExportConfig
onExportConfig,
onLogout
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const activeScenario =
@@ -176,6 +178,7 @@ export function WorkbenchTopBar({
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
onLogout={onLogout}
/>
</div>
</header>
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core";
import {
@@ -50,13 +51,18 @@ const AGENT_PANEL_COLLAPSE_MS = 180;
type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
getAccessToken?: AccessTokenProvider;
};
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
export function useWorkbenchAgent({
onUiEnvelope,
onFrontendAction,
getAccessToken
}: UseWorkbenchAgentOptions) {
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const clientRef = useRef(createAgentApiClient());
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
@@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
() =>
new DefaultChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
headers: async (): Promise<Record<string, string>> => {
const accessToken = await getAccessToken?.();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
},
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return {
body: {
@@ -220,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
};
}
}),
[]
[getAccessToken]
);
const chat = useChat<AgentUiMessage>({
+16 -4
View File
@@ -29,6 +29,7 @@ import {
type MapSourceStatus
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
@@ -76,7 +77,8 @@ import type {
DetailFeature,
ScheduledConditionItem,
ScheduledConditionRecord,
WorkbenchAlert
WorkbenchAlert,
WorkbenchUser
} from "./types";
import {
createScheduledConditionAlerts,
@@ -86,7 +88,15 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
export function MapWorkbenchPage() {
export function MapWorkbenchPage({
user = WORKBENCH_USER,
onLogout,
getAccessToken
}: {
user?: WorkbenchUser;
onLogout?: () => Promise<void>;
getAccessToken?: AccessTokenProvider;
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null);
@@ -129,7 +139,8 @@ export function MapWorkbenchPage() {
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction
onFrontendAction: handleFrontendAction,
getAccessToken
});
const clearActiveTool = useCallback(() => {
setActiveToolId(null);
@@ -766,7 +777,7 @@ export function MapWorkbenchPage() {
scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId}
alerts={workbenchAlerts}
user={WORKBENCH_USER}
user={user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
@@ -785,6 +796,7 @@ export function MapWorkbenchPage() {
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
onExportConfig={handleExportConfig}
onLogout={onLogout}
/>
<WorkbenchAgentPanels
+24 -14
View File
@@ -1,23 +1,33 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "@/app/app";
import { env } from "@/shared/config/env";
import { AuthStartupScreen } from "@/app/auth-startup-screen";
import { initializeAuthentication } from "@/shared/auth/keycloak-auth";
import "@/styles.css";
import "maplibre-gl/dist/maplibre-gl.css";
import "katex/dist/katex.min.css";
import "streamdown/styles.css";
async function prepareMocks() {
if (!env.TJWATER_ENABLE_MSW) return;
const { worker } = await import("@/mocks/browser");
await worker.start({ onUnhandledRequest: "bypass" });
const root = ReactDOM.createRoot(document.getElementById("root")!);
root.render(<AuthStartupScreen />);
try {
const [{ App }, { env }] = await Promise.all([
import("@/app/app"),
import("@/shared/config/env")
]);
if (env.TJWATER_ENABLE_MSW) {
const { worker } = await import("@/mocks/browser");
await worker.start({ onUnhandledRequest: "bypass" });
}
const authentication = await initializeAuthentication(env);
root.render(
<React.StrictMode>
<App authentication={authentication} />
</React.StrictMode>
);
} catch (error) {
console.error("Failed to initialize authentication", error);
root.render(<AuthStartupScreen error />);
}
await prepareMocks();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+90
View File
@@ -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();
});
});
+122
View File
@@ -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;
}
+24 -2
View File
@@ -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();
});
});
+47 -8
View File
@@ -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;
+2
View File
@@ -1 +1,3 @@
import "@testing-library/jest-dom/vitest";
globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" };