diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7a4ecff..0749ae7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -125,8 +125,7 @@ import { import { restoreOIDCBrowserSession } from './lib/oidc-browser-session'; import { consumeOIDCCallbackError, - oidcBrowserSessionEnabled, - oidcLoginEnabled, + loadOIDCRuntimeConfiguration, startOIDCLogin, startOIDCLogout, } from './lib/oidc'; @@ -261,6 +260,7 @@ export function App() { const [state, setState] = useState('idle'); const [error, setError] = useState(''); const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); + const [oidcEnabled, setOIDCEnabled] = useState(false); const loadedDataKeysRef = useRef(new Set()); const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); @@ -294,10 +294,11 @@ export function App() { useEffect(() => { let cancelled = false; - void (async () => { - if (oidcCallbackError) return; - if (!oidcBrowserSessionEnabled()) return; - if (readStoredAccessToken() || !oidcLoginEnabled()) return; + const loadIdentityRuntime = async (force = false) => { + const configuration = await loadOIDCRuntimeConfiguration(force); + if (cancelled) return; + setOIDCEnabled(configuration.enabled && configuration.oidcLogin); + if (oidcCallbackError || readStoredAccessToken() || !configuration.enabled || !configuration.oidcLogin) return; const restored = await restoreOIDCBrowserSession(); if (!restored || cancelled) return; setCurrentUser(restored.user); @@ -305,13 +306,17 @@ export function App() { setToken(restored.credential); setState('idle'); setError(''); - })().catch((err) => { + }; + const runtimeChanged = () => { void loadIdentityRuntime(true); }; + window.addEventListener('identity-runtime-changed', runtimeChanged); + void loadIdentityRuntime().catch((err) => { if (cancelled) return; setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); }); return () => { cancelled = true; + window.removeEventListener('identity-runtime-changed', runtimeChanged); }; }, [oidcCallbackError]); useEffect(() => { @@ -1354,7 +1359,7 @@ export function App() { onSubmitExternalToken={submitExternalToken} onSubmitLogin={submitLogin} onSubmitRegister={submitRegister} - oidcEnabled={oidcLoginEnabled()} + oidcEnabled={oidcEnabled} onOIDCLogin={loginWithOIDC} /> ) @@ -1362,6 +1367,7 @@ export function App() { {activePage === 'admin' && ( isAuthenticated ? ( ) diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index d0cbc8e..a49aa75 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -6,6 +6,8 @@ import { gatewayErrorMessage, getCurrentUser, OIDC_BROWSER_SESSION_CREDENTIAL, + startIdentityPairing, + validateIdentityRevision, } from './api'; describe('Gateway provisioning errors', () => { @@ -35,6 +37,51 @@ describe('Gateway provisioning errors', () => { }); }); +describe('identity configuration transport', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps the one-time onboarding code in the request body and enforces write headers', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'pairing', version: 1 }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await startIdentityPairing('manager-token', { + authCenterUrl: 'https://auth.example.com', + onboardingCode: 'one-time-code-must-stay-in-body', + publicBaseUrl: 'https://api.gateway.example.com', + webBaseUrl: 'https://gateway.example.com', + localTenantKey: 'default', + legacyJwtEnabled: false, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).not.toContain('one-time-code-must-stay-in-body'); + expect(JSON.parse(String(init.body)).onboardingCode).toBe('one-time-code-must-stay-in-body'); + expect(new Headers(init.headers).get('If-Match')).toBe('W/"0"'); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-pair-'); + }); + + it('sends the revision version when validating a draft or active runtime', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'revision', version: 8 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await validateIdentityRevision('manager-token', 'revision', 7); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); + expect(init.credentials).toBe('include'); + }); +}); + describe('security event connection transport', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9232fe4..8fb2644 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -20,6 +20,11 @@ import type { GatewayAuditLog, GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, + IdentityConfigurationRevision, + IdentityConfigurationView, + IdentityPairingExchange, + IdentityPairingInput, + IdentityRevisionPolicyUpdate, GatewayTenant, GatewayTenantUpsertRequest, GatewayNetworkProxyConfig, @@ -52,7 +57,6 @@ import type { WalletSummaryResponse, } from '@easyai-ai-gateway/contracts'; import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; -import { oidcBrowserSessionEnabled } from './lib/oidc'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__'; @@ -644,7 +648,7 @@ export async function* streamChatCompletionText( ...authorizationHeader(token), 'Content-Type': 'application/json', }, - credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin', + credentials: 'include', method: 'POST', signal, }); @@ -845,7 +849,7 @@ export async function uploadFileToStorage( const response = await fetch(`${API_BASE}/v1/files/upload`, { body: form, - credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin', + credentials: 'include', headers: authorizationHeader(token), method: 'POST', }); @@ -1001,6 +1005,64 @@ export async function updateClientCustomizationSettings( }); } +const identityConfigurationPath = '/api/admin/system/identity'; + +export async function getIdentityConfiguration(token: string): Promise { + return request(`${identityConfigurationPath}/configuration`, { token }); +} + +export async function startIdentityPairing(token: string, input: IdentityPairingInput): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/pairings`, 'POST', 0, input, 'pair'); +} + +export async function getIdentityPairing(token: string, pairingId: string): Promise { + return request(`${identityConfigurationPath}/pairings/${pairingId}`, { token }); +} + +export async function updateIdentityRevisionPolicy( + token: string, + revisionId: string, + version: number, + input: IdentityRevisionPolicyUpdate, +): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}`, 'PATCH', version, input, 'policy'); +} + +export async function validateIdentityRevision(token: string, revisionId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/validate`, 'POST', version, undefined, 'validate'); +} + +export async function activateIdentityRevision(token: string, revisionId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate'); +} + +export async function rollbackIdentityRevision(token: string, revisionId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/rollback`, 'POST', version, undefined, 'rollback'); +} + +export async function disableIdentityConfiguration(token: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable'); +} + +function identityConfigurationWrite( + token: string, + path: string, + method: string, + version: number, + body: unknown, + action: string, +): Promise { + return request(path, { + body, + method, + token, + headers: { + 'Idempotency-Key': `identity-${action}-${crypto.randomUUID()}`, + 'If-Match': `W/"${version}"`, + }, + }); +} + const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection'; export async function getSecurityEventConnection(token: string): Promise { @@ -1096,7 +1158,7 @@ async function request( method: options.method ?? 'GET', headers, body: options.body === undefined ? undefined : JSON.stringify(options.body), - credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin', + credentials: 'include', }); if (!response.ok) { const body = await response.text(); diff --git a/apps/web/src/lib/oidc.test.ts b/apps/web/src/lib/oidc.test.ts index f1ea3c2..8a04a9c 100644 --- a/apps/web/src/lib/oidc.test.ts +++ b/apps/web/src/lib/oidc.test.ts @@ -8,8 +8,11 @@ describe('OIDC BFF navigation', () => { }); it('sends only returnTo to the Gateway login endpoint', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), + })); const assign = vi.fn(); vi.stubGlobal('window', { location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign }, @@ -24,8 +27,11 @@ describe('OIDC BFF navigation', () => { }); it('submits logout as a top-level POST without exposing tokens', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), + })); const submit = vi.fn(); const form = { method: '', action: '', style: { display: '' }, submit }; const appendChild = vi.fn(); @@ -39,7 +45,6 @@ describe('OIDC BFF navigation', () => { }); it('turns an invalid login transaction callback into an explicit retry action', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); const replaceState = vi.fn(); vi.stubGlobal('window', { location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' }, @@ -56,7 +61,6 @@ describe('OIDC BFF navigation', () => { }); it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); const replaceState = vi.fn(); vi.stubGlobal('window', { location: { @@ -79,7 +83,6 @@ describe('OIDC BFF navigation', () => { }); it('does not offer transaction retry for a token exchange failure', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); vi.stubGlobal('window', { location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' }, history: { replaceState: vi.fn() }, diff --git a/apps/web/src/lib/oidc.ts b/apps/web/src/lib/oidc.ts index a7bb277..5c39cd0 100644 --- a/apps/web/src/lib/oidc.ts +++ b/apps/web/src/lib/oidc.ts @@ -1,7 +1,18 @@ -const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true'; -const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false'; const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, ''); +export type OIDCRuntimeConfiguration = { + enabled: boolean; + oidcLogin: boolean; + loginUrl?: string; + logoutUrl?: string; + status: string; +}; + +const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' }; +let runtimeConfiguration = disabledRuntime; +let runtimeLoaded = false; +let runtimeRequest: Promise | null = null; + export type OIDCCallbackError = { code: string; reason?: string; @@ -27,32 +38,67 @@ const loginTransactionFailureMessages: Record = { }; export function oidcLoginEnabled() { - return enabled; + return runtimeConfiguration.enabled && runtimeConfiguration.oidcLogin; } export function oidcBrowserSessionEnabled() { - return browserSessionEnabled; + return oidcLoginEnabled(); +} + +export async function loadOIDCRuntimeConfiguration(force = false): Promise { + if (!force && runtimeLoaded) return runtimeConfiguration; + if (!force && runtimeRequest) return runtimeRequest; + runtimeRequest = fetch(`${gatewayAPIBase}/api/v1/public/identity`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }).then(async (response) => { + if (!response.ok) return disabledRuntime; + const value = await response.json() as Partial; + if (typeof value.enabled !== 'boolean' || typeof value.oidcLogin !== 'boolean' || typeof value.status !== 'string') { + return disabledRuntime; + } + return { + enabled: value.enabled, + oidcLogin: value.oidcLogin, + status: value.status, + ...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}), + ...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}), + }; + }).catch(() => disabledRuntime).then((configuration) => { + runtimeConfiguration = configuration; + runtimeLoaded = true; + runtimeRequest = null; + return configuration; + }); + return runtimeRequest; } export async function startOIDCLogin() { - if (!oidcLoginEnabled()) throw new Error('统一认证未配置'); + const configuration = await loadOIDCRuntimeConfiguration(true); + if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置'); const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/'; - const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`); + const loginURL = new URL(identityEndpointURL(configuration.loginUrl)); loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/'); window.location.assign(loginURL.toString()); } export async function startOIDCLogout() { - if (!oidcLoginEnabled()) return false; + const configuration = await loadOIDCRuntimeConfiguration(true); + if (!configuration.enabled || !configuration.oidcLogin || !configuration.logoutUrl) return false; const form = document.createElement('form'); form.method = 'POST'; - form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`; + form.action = identityEndpointURL(configuration.logoutUrl); form.style.display = 'none'; document.body.appendChild(form); form.submit(); return true; } +function identityEndpointURL(path: string) { + if (/^https:\/\//i.test(path) || /^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/i.test(path)) return path; + return `${gatewayAPIBase}/${path.replace(/^\//, '')}`; +} + export function consumeOIDCCallbackError() { const params = new URLSearchParams(window.location.search); const code = params.get('oidcError') ?? ''; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index e406a37..59404bd 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -51,6 +51,7 @@ const tabs = [ ] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>; export function AdminPage(props: { + token: string; data: ConsoleData; operationMessage: string; section: AdminSection; @@ -191,21 +192,16 @@ export function AdminPage(props: { {props.section === 'auditLogs' && } {props.section === 'systemSettings' && ( )} diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 94dccb6..83822a2 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, type FormEvent } from 'react'; -import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react'; -import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts'; +import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2 } from 'lucide-react'; +import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui'; import type { LoadState } from '../../types'; +import { UnifiedIdentityPanel } from './UnifiedIdentityPanel'; -type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents'; +type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity'; type ClientCustomizationForm = { clientEnglishName: string; @@ -54,21 +55,16 @@ const resultUploadPolicyOptions = [ ]; export function SystemSettingsPanel(props: { + token: string; channels: FileStorageChannel[]; clientCustomizationSettings: ClientCustomizationSettings | null; message: string; settings: FileStorageSettings | null; - securityEventConnection: SecurityEventConnectionResponse | null; state: LoadState; onDeleteFileStorageChannel: (channelId: string) => Promise; onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise; onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise; onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; - onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise; - onDisconnectSecurityEvents: () => Promise; - onRefreshSecurityEvents: () => Promise; - onRotateSecurityEventsCredential: () => Promise; - onVerifySecurityEvents: () => Promise; }) { const [activeTab, setActiveTab] = useState('fileStorage'); const [dialogOpen, setDialogOpen] = useState(false); @@ -78,8 +74,6 @@ export function SystemSettingsPanel(props: { const [clientCustomizationForm, setClientCustomizationForm] = useState(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings)); const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy)); const [localError, setLocalError] = useState(''); - const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf'); - const [disconnectOpen, setDisconnectOpen] = useState(false); useEffect(() => { setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy)); @@ -89,13 +83,6 @@ export function SystemSettingsPanel(props: { setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings)); }, [props.clientCustomizationSettings]); - useEffect(() => { - const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus; - if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined; - const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000); - return () => window.clearInterval(timer); - }, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]); - function openCreateDialog() { setEditingChannel(null); setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`)); @@ -177,7 +164,7 @@ export function SystemSettingsPanel(props: { tabs={[ { value: 'fileStorage', label: '文件存储', icon: }, { value: 'clientCustomization', label: '客户端自定义', icon: }, - { value: 'securityEvents', label: '认证中心安全事件', icon: }, + { value: 'identity', label: '统一认证', icon: }, ]} onValueChange={setActiveTab} /> @@ -291,19 +278,7 @@ export function SystemSettingsPanel(props: { )} - {activeTab === 'securityEvents' && ( - setDisconnectOpen(true)} - onRefresh={props.onRefreshSecurityEvents} - onRotate={props.onRotateSecurityEventsCredential} - onVerify={props.onVerifySecurityEvents} - /> - )} + {activeTab === 'identity' && } setPendingDeleteChannel(null)} onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined} /> - setDisconnectOpen(false)} - onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }} - /> ); } -function SecurityEventConnectionPanel(props: { - connection: SecurityEventConnectionResponse | null; - issuer: string; - loading: boolean; - onIssuerChange(value: string): void; - onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise; - onDisconnect(): void; - onRefresh(): Promise; - onRotate(): Promise; - onVerify(): Promise; -}) { - const connection = props.connection?.connection; - const prerequisites = props.connection?.prerequisites; - const [managementClientId, setManagementClientId] = useState(''); - const [managementClientSecret, setManagementClientSecret] = useState(''); - useEffect(() => { - if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId); - }, [managementClientId, prerequisites?.managementClientId]); - const missing = [ - !prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '', - !prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '', - ].filter(Boolean); - return ( -
-
-
- SSF / CAEP 实时会话撤销 - 只需连接认证中心。Gateway 后端自动生成并托管 Push Bearer,复用现有 RFC 7662 机器 Client,不需要重启。 -
- - {securityEventLifecycleLabel(connection?.lifecycleStatus)} - - -
- {!connection && ( - - -
- 连接认证中心 -

请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。

-
- {missing.length > 0 &&
{missing.join(';')}
} - - - - -
-
- )} - {connection && ( - - -
- 机器 Client: {connection.managementClientId} - Receiver Endpoint: {connection.receiverEndpoint} - Issuer: {connection.transmitterIssuer} - Audience: {connection.audience ?? '正在获取'} - Stream ID: {connection.streamId ?? '正在创建'} - 健康模式: {securityEventHealthLabel(connection.healthMode)} - 最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'} - {props.connection?.traceId && 最近操作 Trace ID: {props.connection.traceId}} - {props.connection?.auditId && 最近操作 Audit ID: {props.connection.auditId}} - {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}} -
- {(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') &&
-
认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。
- - - -
} -
- {connection.lifecycleStatus === 'error' && ( - - )} - - - -
-
-
- )} -
- ); -} - -function securityEventLifecycleLabel(status?: string) { - return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record)[status ?? ''] ?? '未连接'; -} - -function securityEventHealthLabel(mode: string) { - return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record)[mode] ?? mode; -} - function defaultClientCustomizationForm(): ClientCustomizationForm { return { clientEnglishName: 'EasyAI AI Gateway', diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx new file mode 100644 index 0000000..100af13 --- /dev/null +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { UnifiedIdentityPanel } from './UnifiedIdentityPanel'; + +describe('UnifiedIdentityPanel', () => { + it('asks only for the standard pairing inputs and does not expose a machine secret field', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('Auth Center 地址'); + expect(html).toContain('一次性接入码'); + expect(html).toContain('Gateway API 公网地址'); + expect(html).toContain('Gateway Web 地址'); + expect(html).toContain('Gateway 本地租户映射'); + expect(html).not.toContain('Machine Client Secret'); + expect(html).not.toContain('SSF Transmitter Issuer'); + }); +}); diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx new file mode 100644 index 0000000..1ee7083 --- /dev/null +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx @@ -0,0 +1,310 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { CheckCircle2, Link2, RefreshCw, RotateCcw, ShieldCheck, Unplug } from 'lucide-react'; +import type { + IdentityConfigurationRevision, + IdentityConfigurationView, + IdentityPairingInput, +} from '@easyai-ai-gateway/contracts'; +import { + activateIdentityRevision, + disableIdentityConfiguration, + getIdentityConfiguration, + rollbackIdentityRevision, + startIdentityPairing, + updateIdentityRevisionPolicy, + validateIdentityRevision, +} from '../../api'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui'; + +const terminalPairingStatuses = new Set(['completed', 'failed', 'expired']); + +export function UnifiedIdentityPanel(props: { token: string }) { + const [configuration, setConfiguration] = useState(null); + const [form, setForm] = useState(defaultPairingInput); + const [showPairingForm, setShowPairingForm] = useState(false); + const [localTenantKey, setLocalTenantKey] = useState('default'); + const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [confirmAction, setConfirmAction] = useState<'disable' | 'rollback' | null>(null); + + async function refresh() { + const next = await getIdentityConfiguration(props.token); + setConfiguration(next); + if (next.draft) { + setLocalTenantKey(next.draft.localTenantKey); + setLegacyJwtEnabled(next.draft.legacyJwtEnabled); + } + return next; + } + + useEffect(() => { + void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置加载失败'))); + }, [props.token]); + + useEffect(() => { + const status = configuration?.pairing?.status; + if (!status || terminalPairingStatuses.has(status)) return undefined; + const timer = window.setInterval(() => { + void refresh().catch(() => undefined); + }, 2000); + return () => window.clearInterval(timer); + }, [configuration?.pairing?.status, props.token]); + + async function run(action: () => Promise, success: string, runtimeChanged = false) { + setLoading(true); + setError(''); + setMessage(''); + try { + await action(); + await refresh(); + setMessage(success); + if (runtimeChanged) window.dispatchEvent(new Event('identity-runtime-changed')); + return true; + } catch (caught) { + setError(errorMessage(caught, '统一认证操作失败')); + return false; + } finally { + setLoading(false); + } + } + + async function submitPairing(event: FormEvent) { + event.preventDefault(); + const input = { ...form }; + setForm((current) => ({ ...current, onboardingCode: '' })); + await run(async () => { + await startIdentityPairing(props.token, input); + setShowPairingForm(false); + }, '接入码已领取,正在由 Gateway 后端准备并保存配置。'); + } + + async function saveDraftPolicy() { + const draft = configuration?.draft; + if (!draft) return; + await run(() => updateIdentityRevisionPolicy(props.token, draft.id, draft.version, { + localTenantKey: localTenantKey.trim(), + legacyJwtEnabled, + }), 'Gateway 本地认证策略已保存。'); + } + + const active = configuration?.active; + const draft = configuration?.draft; + const previous = configuration?.previous; + const pairing = configuration?.pairing; + const runtime = configuration?.runtime; + const shouldShowPairing = showPairingForm || (!active && !pairing); + + return ( +
+
+
+ 统一认证 + 使用认证中心生成的一次性接入码完成标准应用配对;Gateway 自动配置 OIDC、Introspection 与可选 SSF。 +
+
+ {active ? '已启用' : '未启用'} + +
+
+ + {(message || error) &&
{error || message}
} + + {active && runtime && ( + + +
+ 当前生效配置 +

Revision {shortID(active.id)} · {active.issuer}

+
+ Active +
+ +
+ + + + +
+ +
+ + + {previous && ( + + )} + +
+
+
+ )} + + {pairing && !terminalPairingStatuses.has(pairing.status) && ( + + +
+
正在配对认证中心{pairingStatusLabel(pairing.status)}
+ {pairing.status} +
+
+ Exchange: {shortID(pairing.remoteExchangeId)} + 有效期至: {new Date(pairing.expiresAt).toLocaleString()} + {pairing.lastTraceId && Trace ID: {pairing.lastTraceId}} + {pairing.authCenterAuditId && Auth Center Audit ID: {pairing.authCenterAuditId}} +
+
+
+ )} + + {draft && pairing?.status === 'completed' && ( + + +
待启用配置

机器凭据已写入 SecretStore,页面不会显示或保存明文。

+ {draft.state} +
+ + + {draft.state === 'draft' && ( +
+ + +
+ )} +
+ {draft.state === 'draft' && <> + + + } + {draft.state === 'validated' && ( + + )} + {draft.state === 'failed' && 验证失败:{draft.lastErrorCategory || 'validation_failed'}。请检查地址和租户后使用新接入码重新配对。} +
+
+
+ )} + + {pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && ( +
+ 配对{pairing.status === 'expired' ? '已过期' : '失败'}:{pairing.lastErrorCategory || pairing.status}。请在认证中心生成新的接入码。 + +
+ )} + + {shouldShowPairing && ( + + +
{active ? '重新配对认证中心' : '接入认证中心'}

认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。

+ 接入码 10 分钟有效 +
+ +
void submitPairing(event)}> + + + + + + +
+ + {active && } +
+
+
+
+ )} + + setConfirmAction(null)} + onConfirm={async () => { + let succeeded = false; + if (confirmAction === 'rollback' && previous) { + succeeded = await run(() => rollbackIdentityRevision(props.token, previous.id, previous.version), '统一认证已回滚,用户需要重新登录。', true); + } else if (confirmAction === 'disable' && active) { + succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true); + } + if (succeeded) setConfirmAction(null); + }} + /> +
+ ); +} + +function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) { + return ( +
+ Issuer: {revision.issuer || '等待 Manifest'} + Audience: {revision.audience || '等待 Manifest'} + Tenant: {revision.tenantId || '等待 Manifest'} + 本地租户: {revision.localTenantKey} + 登录 Client: {revision.browserClientId || '未启用'} + 服务 Client: {revision.machineClientId || '未启用'} + 能力: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'} + Scope: {revision.scopes.join(', ') || '无'} + {revision.lastTraceId && Trace ID: {revision.lastTraceId}} + {revision.lastAuditId && Audit ID: {revision.lastAuditId}} +
+ ); +} + +function IdentityHealth({ label, value }: { label: string; value: string }) { + const healthy = value === 'healthy' || value === 'push_healthy'; + return
{label}{healthLabel(value)}
; +} + +function defaultPairingInput(): IdentityPairingInput { + const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin; + return { + authCenterUrl: 'https://auth.51easyai.com', + onboardingCode: '', + publicBaseUrl: (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''), + webBaseUrl, + localTenantKey: 'default', + legacyJwtEnabled: false, + }; +} + +function pairingStatusLabel(status: string) { + return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record)[status] ?? status; +} + +function capabilityLabel(value: string) { + return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record)[value] ?? value; +} + +function healthLabel(value: string) { + return ({ healthy: '健康', disabled: '未启用', push_healthy: 'Push 健康', introspection_fallback: 'Introspection 降级', bootstrap: '启动保护' } as Record)[value] ?? value; +} + +function shortID(value: string) { return value ? `${value.slice(0, 8)}…` : '-'; } + +function errorMessage(caught: unknown, fallback: string) { return caught instanceof Error ? caught.message : fallback; } diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index df8eb40..fead9df 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -1995,6 +1995,44 @@ line-height: 1.45; } +.identityHealthGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.identityHealthItem { + align-items: center; + background: var(--color-surface-subtle, #f8fafc); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 10px; + display: flex; + justify-content: space-between; + min-height: 52px; + padding: 10px 12px; +} + +.identityCheckbox { + align-items: center; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 10px; + display: flex; + gap: 10px; + min-height: 66px; + padding: 10px 12px; +} + +.identityCheckbox span, +.identityCheckbox small { + display: block; +} + +@media (max-width: 900px) { + .identityHealthGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .fileStorageGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 00faee8..fbf66db 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -988,6 +988,117 @@ export interface SecurityEventConnectionResponse { prerequisites?: SecurityEventConnectionPrerequisites; } +export type IdentityRevisionState = 'draft' | 'validated' | 'active' | 'superseded' | 'failed'; + +export interface IdentityConfigurationRevision { + id: string; + state: IdentityRevisionState; + schemaVersion: number; + authCenterUrl: string; + issuer?: string; + tenantId?: string; + applicationId?: string; + audience?: string; + browserClientId?: string; + machineClientId?: string; + scopes: string[]; + capabilities: string[]; + rolePrefix: string; + localTenantKey: string; + publicBaseUrl: string; + webBaseUrl: string; + jitEnabled: boolean; + legacyJwtEnabled: boolean; + tokenIntrospection: boolean; + sessionRevocation: boolean; + securityEventIssuer?: string; + securityEventConfigurationUrl?: string; + securityEventAudience?: string; + sessionIdleSeconds: number; + sessionAbsoluteSeconds: number; + sessionRefreshSeconds: number; + version: number; + lastErrorCategory?: string; + lastTraceId?: string; + lastAuditId?: string; + validatedAt?: string; + activatedAt?: string; + supersededAt?: string; + createdAt: string; + updatedAt: string; +} + +export type IdentityPairingStatus = + | 'metadata_pending' + | 'preparing' + | 'ready' + | 'credentials_saved' + | 'completed' + | 'failed' + | 'expired'; + +export interface IdentityPairingExchange { + id: string; + revisionId: string; + remoteExchangeId: string; + status: IdentityPairingStatus; + remoteVersion: number; + expiresAt: string; + version: number; + lastErrorCategory?: string; + authCenterAuditId?: string; + lastTraceId?: string; + createdAt: string; + updatedAt: string; +} + +export interface IdentityRuntimeStatus { + enabled: boolean; + revisionId?: string; + login: string; + jit: string; + tokenIntrospection: string; + sessionRevocation: string; + lastTraceId?: string; + lastAuditId?: string; + lastErrorCategory?: string; +} + +export interface IdentityConfigurationView { + active: IdentityConfigurationRevision | null; + draft: IdentityConfigurationRevision | null; + previous: IdentityConfigurationRevision | null; + pairing?: IdentityPairingExchange; + runtime: IdentityRuntimeStatus; +} + +export interface PublicIdentityConfiguration { + enabled: boolean; + oidcLogin: boolean; + loginUrl?: string; + logoutUrl?: string; + status: string; +} + +export interface IdentityPairingInput { + authCenterUrl: string; + onboardingCode: string; + publicBaseUrl: string; + webBaseUrl: string; + localTenantKey: string; + legacyJwtEnabled: boolean; +} + +export interface IdentityRevisionPolicyUpdate { + localTenantKey?: string; + rolePrefix?: string; + jitEnabled?: boolean; + legacyJwtEnabled?: boolean; + sessionIdleSeconds?: number; + sessionAbsoluteSeconds?: number; + sessionRefreshSeconds?: number; +} + export interface GatewayTask { id: string; kind: string;