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
+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>;