fix: 修复 OIDC 用户预配与跨标签页登录态

增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
2026-07-13 17:07:52 +08:00
parent 17b1f77e1d
commit a81a7b5200
37 changed files with 2694 additions and 179 deletions
+80 -14
View File
@@ -50,6 +50,7 @@ import {
deleteApiKey,
deleteFileStorageChannel,
deleteGatewayUser,
deleteOIDCBrowserSession,
deletePlatform,
deleteTenant,
deleteUserGroup,
@@ -87,6 +88,7 @@ import {
listUserGroups,
listUsers,
loginLocalAccount,
OIDC_BROWSER_SESSION_CREDENTIAL,
pollTaskUntilSettled,
registerLocalAccount,
rechargeUserWalletBalance,
@@ -110,8 +112,20 @@ import { LoginRequiredPanel } from './components/LoginRequiredPanel';
import { useCatalogOperations } from './hooks/useCatalogOperations';
import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations';
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
import { persistAccessToken, readStoredAccessToken } from './lib/auth-storage';
import { completeOIDCLogin, oidcLoginEnabled, startOIDCLogin, startOIDCLogout } from './lib/oidc';
import {
persistAccessToken,
persistLegacyOIDCAccessToken,
readLegacyOIDCAccessToken,
readStoredAccessToken,
} from './lib/auth-storage';
import { activateOIDCBrowserSession, restoreOIDCBrowserSession } from './lib/oidc-browser-session';
import {
completeOIDCLogin,
oidcBrowserSessionEnabled,
oidcLoginEnabled,
startOIDCLogin,
startOIDCLogout,
} from './lib/oidc';
import { runTask } from './lib/run-task';
import { AdminPage } from './pages/AdminPage';
import { ApiDocsPage } from './pages/ApiDocsPage';
@@ -272,17 +286,45 @@ export function App() {
currentTransactionQueryKeyRef.current = transactionListRequestKey;
useEffect(() => {
void completeOIDCLogin()
.then((result) => {
if (!result) return;
persistAccessToken(result.accessToken, 'session');
setToken(result.accessToken);
let cancelled = false;
void (async () => {
const result = await completeOIDCLogin();
if (cancelled) return;
if (result) {
if (!oidcBrowserSessionEnabled()) {
persistLegacyOIDCAccessToken(result.accessToken);
setToken(result.accessToken);
applyRoute(parseAppRoute(result.returnTo));
return;
}
const credential = await activateOIDCBrowserSession(result.accessToken);
if (cancelled) return;
setToken(credential);
applyRoute(parseAppRoute(result.returnTo));
})
.catch((err) => {
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
});
return;
}
if (!oidcBrowserSessionEnabled()) return;
const legacyOIDCToken = readLegacyOIDCAccessToken();
if (legacyOIDCToken) {
const credential = await activateOIDCBrowserSession(legacyOIDCToken);
if (!cancelled) setToken(credential);
return;
}
if (readStoredAccessToken() || !oidcLoginEnabled()) return;
const restored = await restoreOIDCBrowserSession();
if (!restored || cancelled) return;
setCurrentUser(restored.user);
loadedDataKeysRef.current.add('currentUser');
setToken(restored.credential);
})().catch((err) => {
if (cancelled) return;
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void ensureData(['health']);
@@ -402,6 +444,7 @@ export function App() {
try {
await Promise.all(requestKeys.map((key) => loadDataKey(key, nextToken)));
requestKeys.forEach((key) => loadedDataKeysRef.current.add(key));
setError('');
setState('ready');
} catch (err) {
if (handleAuthExpired(err, nextToken)) return;
@@ -1026,7 +1069,9 @@ export function App() {
const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : '';
const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id;
const credential = selectedApiKeySecret || (fallbackApiKeySecret ? apiKeySecretsById[fallbackApiKeySecret] : '') || apiKeySecret || token;
const credentialLabel = selectedApiKeySecret || fallbackApiKeySecret || apiKeySecret ? '本地 API Key' : '当前 Access Token';
const usingLocalAPIKey = Boolean(selectedApiKeySecret || fallbackApiKeySecret || apiKeySecret);
let credentialLabel = usingLocalAPIKey ? '本地 API Key' : '当前 Access Token';
if (!usingLocalAPIKey && token === OIDC_BROWSER_SESSION_CREDENTIAL) credentialLabel = '当前登录会话';
setCoreState('loading');
setCoreMessage('');
try {
@@ -1104,8 +1149,28 @@ export function App() {
}
async function signOut() {
const wasOIDCSession = token === OIDC_BROWSER_SESSION_CREDENTIAL;
if (wasOIDCSession) {
try {
await deleteOIDCBrowserSession();
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '统一认证会话注销失败,请重试');
return;
}
}
const shouldEndOIDCSession = wasOIDCSession || currentUser?.source === 'oidc';
resetAuthenticatedSession();
if (await startOIDCLogout()) return;
if (shouldEndOIDCSession) {
try {
if (await startOIDCLogout()) return;
} catch {
navigatePath('/');
setState('error');
setError('Gateway 会话已注销,但统一认证退出失败');
return;
}
}
navigatePath('/');
}
@@ -1137,6 +1202,7 @@ export function App() {
}
function navigatePath(path: string) {
setError('');
if (`${window.location.pathname}${window.location.search}` !== path) {
window.history.pushState(null, '', path);
}
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createOIDCBrowserSession,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
getCurrentUser,
OIDC_BROWSER_SESSION_CREDENTIAL,
} from './api';
describe('Gateway provisioning errors', () => {
const cases = [
['GATEWAY_USER_NOT_PROVISIONED', '该账号尚未开通 EasyAI Gateway'],
['GATEWAY_USER_DISABLED', '该 Gateway 账号已停用,请联系管理员'],
['GATEWAY_TENANT_UNAVAILABLE', 'Gateway 租户尚未就绪,请联系管理员'],
['GATEWAY_USER_PROVISIONING_FAILED', 'Gateway 账号初始化失败,请稍后重试'],
['OIDC_BROWSER_SESSION_DISABLED', 'Gateway 浏览器会话尚未启用'],
['OIDC_SESSION_INVALID', '统一认证会话无效,请重新登录'],
['OIDC_SESSION_TOKEN_TOO_LARGE', '统一认证凭证过大,无法建立浏览器会话'],
['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'],
] as const;
for (const [code, expected] of cases) {
it(`maps ${code} to a stable Chinese status`, () => {
expect(gatewayErrorMessage({ code, message: 'internal server message', status: 503 })).toBe(expected);
expect(new GatewayApiError({ code, message: 'internal server message', status: 503 }).message).toContain(expected);
expect(new GatewayApiError({ code, message: 'internal server message', status: 503 }).message).not.toContain('internal server message');
});
}
it('keeps the server message for unrelated errors', () => {
expect(gatewayErrorMessage({ code: 'OTHER_ERROR', message: '模型不可用', status: 503 })).toBe('模型不可用');
});
});
describe('OIDC browser session transport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('exchanges the in-memory access token for a credentialed HttpOnly session', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await createOIDCBrowserSession('auth-center-access-token');
expect(fetchMock).toHaveBeenCalledOnce();
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.credentials).toBe('include');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer auth-center-access-token');
expect(init.body).toBeUndefined();
});
it('uses the shared cookie without sending a synthetic bearer token', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ sub: 'oidc-user' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.credentials).toBe('include');
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
it('deletes the shared cookie with a credentialed request', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await deleteOIDCBrowserSession();
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('DELETE');
expect(init.credentials).toBe('include');
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});
+44 -7
View File
@@ -51,10 +51,12 @@ 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__';
interface GatewayErrorDetails {
export interface GatewayErrorDetails {
code?: string;
message: string;
requestId?: string;
@@ -110,6 +112,20 @@ export async function loginLocalAccount(input: { account: string; password: stri
});
}
export async function createOIDCBrowserSession(accessToken: string): Promise<void> {
await request<void>('/api/v1/auth/oidc/session', {
method: 'POST',
token: accessToken,
});
}
export async function deleteOIDCBrowserSession(): Promise<void> {
await request<void>('/api/v1/auth/oidc/session', {
auth: false,
method: 'DELETE',
});
}
export async function getCurrentUser(token: string): Promise<AuthUser> {
return request<AuthUser>('/api/v1/me', { token });
}
@@ -631,9 +647,10 @@ export async function* streamChatCompletionText(
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
body: JSON.stringify({ ...input, stream: true }),
headers: {
Authorization: `Bearer ${token}`,
...authorizationHeader(token),
'Content-Type': 'application/json',
},
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
method: 'POST',
signal,
});
@@ -834,9 +851,8 @@ export async function uploadFileToStorage(
const response = await fetch(`${API_BASE}/v1/files/upload`, {
body: form,
headers: {
Authorization: `Bearer ${token}`,
},
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
headers: authorizationHeader(token),
method: 'POST',
});
const body = await response.text();
@@ -1030,7 +1046,7 @@ async function request<T>(
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
): Promise<T> {
const headers: Record<string, string> = { ...(options.headers ?? {}) };
if (options.auth !== false && options.token) {
if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) {
headers.Authorization = `Bearer ${options.token}`;
}
if (options.body !== undefined) {
@@ -1040,6 +1056,7 @@ async function request<T>(
method: options.method ?? 'GET',
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
});
if (!response.ok) {
const body = await response.text();
@@ -1051,6 +1068,11 @@ async function request<T>(
return response.json() as Promise<T>;
}
function authorizationHeader(token: string): Record<string, string> {
if (!token || token === OIDC_BROWSER_SESSION_CREDENTIAL) return {};
return { Authorization: `Bearer ${token}` };
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -1122,7 +1144,7 @@ function errorDetailsFromParsed(parsed: unknown, status?: number, fallback = '')
}
function formatGatewayErrorDetails(details: GatewayErrorDetails) {
const message = details.message || '请求失败';
const message = gatewayErrorMessage(details);
const meta = [
details.code ? `错误码: ${details.code}` : '',
details.status ? `状态: ${details.status}` : '',
@@ -1132,6 +1154,21 @@ function formatGatewayErrorDetails(details: GatewayErrorDetails) {
return meta.length ? `${message}${meta.join('')}` : message;
}
const gatewayProvisioningErrorMessages: Record<string, string> = {
GATEWAY_USER_NOT_PROVISIONED: '该账号尚未开通 EasyAI Gateway',
GATEWAY_USER_DISABLED: '该 Gateway 账号已停用,请联系管理员',
GATEWAY_TENANT_UNAVAILABLE: 'Gateway 租户尚未就绪,请联系管理员',
GATEWAY_USER_PROVISIONING_FAILED: 'Gateway 账号初始化失败,请稍后重试',
OIDC_BROWSER_SESSION_DISABLED: 'Gateway 浏览器会话尚未启用',
OIDC_SESSION_INVALID: '统一认证会话无效,请重新登录',
OIDC_SESSION_TOKEN_TOO_LARGE: '统一认证凭证过大,无法建立浏览器会话',
OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试',
};
export function gatewayErrorMessage(details: GatewayErrorDetails) {
return (details.code && gatewayProvisioningErrorMessages[details.code]) || details.message || '请求失败';
}
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
+12 -7
View File
@@ -13,16 +13,21 @@ describe('auth storage', () => {
vi.stubGlobal('window', { localStorage: new MemoryStorage(), sessionStorage: new MemoryStorage() });
});
it('keeps OIDC access tokens in session storage and removes persistent tokens', () => {
persistAccessToken('legacy-token');
persistAccessToken('oidc-token', 'session');
expect(readStoredAccessToken()).toBe('oidc-token');
expect(window.localStorage.getItem('easyai_ai_gateway_access_token')).toBeNull();
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBe('oidc-token');
it('persists only explicit local or externally supplied bearer tokens', () => {
persistAccessToken('local-token');
expect(readStoredAccessToken()).toBe('local-token');
expect(window.localStorage.getItem('easyai_ai_gateway_access_token')).toBe('local-token');
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull();
});
it('reads a legacy OIDC session token only for one-time cookie migration', () => {
window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'legacy-oidc-token');
expect(readStoredAccessToken()).toBe('legacy-oidc-token');
});
it('clears both token stores on logout', () => {
persistAccessToken('oidc-token', 'session');
window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'legacy-oidc-token');
persistAccessToken('local-token');
persistAccessToken('');
expect(readStoredAccessToken()).toBe('');
});
+21 -4
View File
@@ -12,15 +12,32 @@ export function readStoredAccessToken() {
}
}
export function persistAccessToken(value: string, storage: 'local' | 'session' = 'local') {
export function readLegacyOIDCAccessToken() {
if (typeof window === 'undefined') return '';
try {
return window.sessionStorage.getItem(OIDC_SESSION_TOKEN_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
export function persistLegacyOIDCAccessToken(value: string) {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
if (value) window.sessionStorage.setItem(OIDC_SESSION_TOKEN_STORAGE_KEY, value);
else window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY);
} catch {
// Compatibility-only rollback path for deployments with browser sessions disabled.
}
}
export function persistAccessToken(value: string) {
if (typeof window === 'undefined') return;
try {
if (!value) {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY);
} else if (storage === 'session') {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.sessionStorage.setItem(OIDC_SESSION_TOKEN_STORAGE_KEY, value);
} else {
window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY);
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, value);
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OIDC_BROWSER_SESSION_CREDENTIAL } from '../api';
import { activateOIDCBrowserSession, restoreOIDCBrowserSession } from './oidc-browser-session';
class MemoryStorage {
private values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
describe('OIDC browser session lifecycle', () => {
beforeEach(() => {
vi.stubGlobal('window', { localStorage: new MemoryStorage(), sessionStorage: new MemoryStorage() });
});
it('moves an OIDC access token into the HttpOnly session and clears browser token storage', async () => {
window.localStorage.setItem('easyai_ai_gateway_access_token', 'old-local-token');
window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'old-oidc-token');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
const credential = await activateOIDCBrowserSession('new-oidc-token');
expect(credential).toBe(OIDC_BROWSER_SESSION_CREDENTIAL);
expect(window.localStorage.getItem('easyai_ai_gateway_access_token')).toBeNull();
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull();
});
it('restores a shared cookie session in a fresh tab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
sub: 'platform-user', source: 'oidc', gatewayUserId: 'gateway-user',
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const restored = await restoreOIDCBrowserSession();
expect(restored?.credential).toBe(OIDC_BROWSER_SESSION_CREDENTIAL);
expect(restored?.user.sub).toBe('platform-user');
});
it('treats a missing or expired shared cookie as a signed-out tab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: 'unauthorized', status: 401 },
}), { status: 401, headers: { 'Content-Type': 'application/json' } })));
await expect(restoreOIDCBrowserSession()).resolves.toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
import {
createOIDCBrowserSession,
GatewayApiError,
getCurrentUser,
OIDC_BROWSER_SESSION_CREDENTIAL,
} from '../api';
import { persistAccessToken } from './auth-storage';
type CurrentUser = Awaited<ReturnType<typeof getCurrentUser>>;
export interface RestoredOIDCBrowserSession {
credential: typeof OIDC_BROWSER_SESSION_CREDENTIAL;
user: CurrentUser;
}
export async function activateOIDCBrowserSession(accessToken: string) {
if (!accessToken.trim()) throw new Error('统一认证未返回 Access Token');
await createOIDCBrowserSession(accessToken);
persistAccessToken('');
return OIDC_BROWSER_SESSION_CREDENTIAL;
}
export async function restoreOIDCBrowserSession(): Promise<RestoredOIDCBrowserSession | null> {
try {
const user = await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL);
return { credential: OIDC_BROWSER_SESSION_CREDENTIAL, user };
} catch (error) {
if (error instanceof GatewayApiError && error.details.status === 401) return null;
throw error;
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
const issuer = (import.meta.env.VITE_OIDC_ISSUER ?? '').replace(/\/$/, '');
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID ?? '';
const configuredRedirect = import.meta.env.VITE_OIDC_REDIRECT_URI ?? '';
@@ -25,6 +26,10 @@ export function oidcLoginEnabled() {
return enabled && Boolean(issuer && clientId);
}
export function oidcBrowserSessionEnabled() {
return browserSessionEnabled;
}
export async function startOIDCLogin() {
assertConfigured();
const discovery = await getDiscovery();
@@ -81,7 +86,6 @@ async function completeOIDCLoginOnce(): Promise<{ accessToken: string; returnTo:
if (!payload.access_token) throw new Error('统一认证未返回 Access Token');
if (!payload.id_token) throw new Error('统一认证未返回 ID Token');
validateIDToken(payload.id_token, transaction.nonce);
window.sessionStorage.setItem(idTokenKey, payload.id_token);
window.history.replaceState({}, '', transaction.returnTo || '/');
return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' };
}
@@ -91,10 +95,10 @@ export async function startOIDCLogout() {
const idToken = window.sessionStorage.getItem(idTokenKey);
window.sessionStorage.removeItem(idTokenKey);
window.sessionStorage.removeItem(transactionKey);
if (!idToken) return false;
const discovery = await getDiscovery();
if (!discovery.end_session_endpoint) return false;
const params = new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: window.location.origin + '/' });
const params = new URLSearchParams({ client_id: clientId, post_logout_redirect_uri: window.location.origin + '/' });
if (idToken) params.set('id_token_hint', idToken);
window.location.assign(`${discovery.end_session_endpoint}?${params}`);
return true;
}