fix: 修复 OIDC 用户预配与跨标签页登录态
增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
+44
-7
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user