feat: 将 Gateway Web 迁移到 BFF 会话

浏览器只通过 HttpOnly Cookie 恢复统一认证状态,不再访问认证中心 Token Endpoint 或保存 OIDC Token。同步更新错误提示、部署配置和接入文档。
This commit is contained in:
chengcheng 2026-07-13 19:09:57 +08:00
parent c5c82bb528
commit 46bac51703
15 changed files with 143 additions and 287 deletions

View File

@ -42,9 +42,19 @@ OIDC_GATEWAY_TENANT_KEY=
OIDC_BROWSER_SESSION_ENABLED=true
# Staging/production must use true. Local HTTP development may use false.
OIDC_SESSION_COOKIE_SECURE=false
# Public Client ID generated by Auth Center Control Plane. No Client Secret is used.
OIDC_CLIENT_ID=
OIDC_REDIRECT_URI=http://localhost:5178/auth/callback
OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/
# Generate a dedicated value with: openssl rand -base64 32
# Keep the real value only in a Git-ignored local file or Secret Manager.
OIDC_SESSION_ENCRYPTION_KEY=
OIDC_SESSION_IDLE_TTL_SECONDS=1800
OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800
OIDC_SESSION_REFRESH_BEFORE_SECONDS=60
VITE_OIDC_BROWSER_SESSION_ENABLED=true
VITE_OIDC_ENABLED=false
AI_GATEWAY_WEB_BASE_URL=http://localhost:5178
AI_GATEWAY_WEB_BASE_PATH=/
AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine
AI_GATEWAY_API_RUNTIME_IMAGE=alpine:3.22

View File

@ -72,16 +72,10 @@ COPY apps/web apps/web
ARG VITE_GATEWAY_API_BASE_URL=/gateway-api
ARG VITE_OIDC_ENABLED=false
ARG VITE_OIDC_ISSUER=
ARG VITE_OIDC_CLIENT_ID=
ARG VITE_OIDC_REDIRECT_URI=
ARG VITE_OIDC_BROWSER_SESSION_ENABLED=true
ARG VITE_BASE_PATH=/
ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL
ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \
VITE_OIDC_ISSUER=$VITE_OIDC_ISSUER \
VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \
VITE_OIDC_REDIRECT_URI=$VITE_OIDC_REDIRECT_URI \
VITE_OIDC_BROWSER_SESSION_ENABLED=$VITE_OIDC_BROWSER_SESSION_ENABLED \
VITE_BASE_PATH=$VITE_BASE_PATH
RUN pnpm --filter @easyai-ai-gateway/web build

View File

@ -46,11 +46,15 @@ OIDC_JIT_PROVISIONING_ENABLED=true
OIDC_GATEWAY_TENANT_KEY=default
OIDC_BROWSER_SESSION_ENABLED=true
OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP生产必须为 true
OIDC_CLIENT_ID=<公共 Client ID>
OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/
OIDC_SESSION_ENCRYPTION_KEY=<base64 编码的独立 32 字节随机密钥>
```
`OIDC_GATEWAY_TENANT_KEY` 必须指向已有且启用的 Gateway 租户及其默认用户组JIT 不会从 Token 自动创建租户。开关默认关闭,关闭后不再创建新用户,但仍可解析此前创建的 `source=oidc` 映射。
Web Console 默认把已验证的 Auth Center Access Token 转入 `HttpOnly + SameSite=Strict` Cookie随后清除浏览器 `localStorage/sessionStorage` 中的 OIDC Access Token。Cookie 在同域标签页之间共享且有效期不超过原 Access TokenGateway 不会二次签发 JWT。Staging、生产及其他非本地环境必须启用 Secure Cookie并配置明确的 `CORS_ALLOWED_ORIGIN`,禁止 `*`。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。
Web Console 使用公共 Client + PKCE + Gateway BFF SessionGateway 服务端换码并加密保存 Access/Refresh/ID Token浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。
`pnpm dev` 会先创建数据库并执行 migration然后并行启动

View File

@ -50,7 +50,6 @@ import {
deleteApiKey,
deleteFileStorageChannel,
deleteGatewayUser,
deleteOIDCBrowserSession,
deletePlatform,
deleteTenant,
deleteUserGroup,
@ -114,13 +113,11 @@ import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
import {
persistAccessToken,
persistLegacyOIDCAccessToken,
readLegacyOIDCAccessToken,
readStoredAccessToken,
} from './lib/auth-storage';
import { activateOIDCBrowserSession, restoreOIDCBrowserSession } from './lib/oidc-browser-session';
import { restoreOIDCBrowserSession } from './lib/oidc-browser-session';
import {
completeOIDCLogin,
consumeOIDCCallbackError,
oidcBrowserSessionEnabled,
oidcLoginEnabled,
startOIDCLogin,
@ -254,6 +251,7 @@ export function App() {
const [coreMessage, setCoreMessage] = useState('');
const [state, setState] = useState<LoadState>('idle');
const [error, setError] = useState('');
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
const loadedDataKeysRef = useRef(new Set<DataKey>());
const loadingDataKeysRef = useRef(new Set<DataKey>());
const loadedTaskQueryKeyRef = useRef('');
@ -288,35 +286,16 @@ export function App() {
useEffect(() => {
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));
return;
}
if (oidcCallbackError) 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);
setState('idle');
setError('');
})().catch((err) => {
if (cancelled) return;
setState('error');
@ -325,7 +304,10 @@ export function App() {
return () => {
cancelled = true;
};
}, []);
}, [oidcCallbackError]);
useEffect(() => {
if (token) setOIDCCallbackError('');
}, [token]);
useEffect(() => {
void ensureData(['health']);
}, []);
@ -1150,33 +1132,27 @@ 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 (shouldEndOIDCSession) {
try {
if (await startOIDCLogout()) return;
if (await startOIDCLogout()) {
resetAuthenticatedSession();
return;
}
} catch {
navigatePath('/');
setState('error');
setError('Gateway 会话已注销,但统一认证退出失败');
setError('统一认证退出失败,请重试');
return;
}
}
resetAuthenticatedSession();
navigatePath('/');
}
function loginWithOIDC() {
setState('loading');
setError('');
setOIDCCallbackError('');
void startOIDCLogin().catch((err) => {
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
@ -1255,7 +1231,7 @@ export function App() {
onRefresh={() => void refresh()}
onSignOut={signOut}
>
{error && <div className="notice">{error}</div>}
{(oidcCallbackError || error) && <div className="notice">{oidcCallbackError || error}</div>}
{activePage === 'home' && <HomePage onNavigate={navigatePage} onPlaygroundMode={navigatePlaygroundMode} />}
{activePage === 'playground' && (
<PlaygroundPage

View File

@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createOIDCBrowserSession,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
@ -16,7 +15,9 @@ describe('Gateway provisioning errors', () => {
['GATEWAY_USER_PROVISIONING_FAILED', 'Gateway 账号初始化失败,请稍后重试'],
['OIDC_BROWSER_SESSION_DISABLED', 'Gateway 浏览器会话尚未启用'],
['OIDC_SESSION_INVALID', '统一认证会话无效,请重新登录'],
['OIDC_SESSION_TOKEN_TOO_LARGE', '统一认证凭证过大,无法建立浏览器会话'],
['OIDC_SESSION_EXPIRED', '统一认证会话已过期,请重新登录'],
['OIDC_SESSION_REFRESH_UNAVAILABLE', '认证中心暂时不可用,请稍后重试'],
['OIDC_SESSION_STORE_UNAVAILABLE', '登录会话存储暂时不可用,请稍后重试'],
['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'],
] as const;
@ -38,19 +39,6 @@ describe('OIDC browser session transport', () => {
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,

View File

@ -112,13 +112,6 @@ 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,
@ -1161,7 +1154,9 @@ const gatewayProvisioningErrorMessages: Record<string, string> = {
GATEWAY_USER_PROVISIONING_FAILED: 'Gateway 账号初始化失败,请稍后重试',
OIDC_BROWSER_SESSION_DISABLED: 'Gateway 浏览器会话尚未启用',
OIDC_SESSION_INVALID: '统一认证会话无效,请重新登录',
OIDC_SESSION_TOKEN_TOO_LARGE: '统一认证凭证过大,无法建立浏览器会话',
OIDC_SESSION_EXPIRED: '统一认证会话已过期,请重新登录',
OIDC_SESSION_REFRESH_UNAVAILABLE: '认证中心暂时不可用,请稍后重试',
OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试',
OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试',
};

View File

@ -20,9 +20,10 @@ describe('auth storage', () => {
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull();
});
it('reads a legacy OIDC session token only for one-time cookie migration', () => {
it('removes and never reads a legacy OIDC browser token', () => {
window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'legacy-oidc-token');
expect(readStoredAccessToken()).toBe('legacy-oidc-token');
expect(readStoredAccessToken()).toBe('');
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull();
});
it('clears both token stores on logout', () => {

View File

@ -4,34 +4,14 @@ const OIDC_SESSION_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_oidc_access_token';
export function readStoredAccessToken() {
if (typeof window === 'undefined') return '';
try {
return window.sessionStorage.getItem(OIDC_SESSION_TOKEN_STORAGE_KEY)
?? window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
?? '';
// Remove credentials written by the retired browser-token implementation.
window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY);
return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
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 {

View File

@ -1,31 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { 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); }
}
import { restoreOIDCBrowserSession } from './oidc-browser-session';
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',

View File

@ -1,10 +1,8 @@
import {
createOIDCBrowserSession,
GatewayApiError,
getCurrentUser,
OIDC_BROWSER_SESSION_CREDENTIAL,
} from '../api';
import { persistAccessToken } from './auth-storage';
type CurrentUser = Awaited<ReturnType<typeof getCurrentUser>>;
@ -13,13 +11,6 @@ export interface RestoredOIDCBrowserSession {
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);

View File

@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('OIDC BFF navigation', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
});
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');
const assign = vi.fn();
vi.stubGlobal('window', {
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
});
const { startOIDCLogin } = await import('./oidc');
await startOIDCLogin();
const target = new URL(String(assign.mock.calls[0]?.[0]));
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance');
expect(target.searchParams.has('client_id')).toBe(false);
expect(target.searchParams.has('code_challenge')).toBe(false);
});
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');
const submit = vi.fn();
const form = { method: '', action: '', style: { display: '' }, submit };
const appendChild = vi.fn();
vi.stubGlobal('document', { createElement: vi.fn(() => form), body: { appendChild } });
const { startOIDCLogout } = await import('./oidc');
await expect(startOIDCLogout()).resolves.toBe(true);
expect(form.method).toBe('POST');
expect(form.action).toBe('https://gateway.example.com/gateway-api/api/v1/auth/oidc/logout');
expect(Object.keys(form)).not.toContain('accessToken');
expect(submit).toHaveBeenCalledOnce();
});
});

View File

@ -1,29 +1,18 @@
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 ?? '';
const transactionKey = 'easyai.gateway.oidc.transaction';
const idTokenKey = 'easyai.gateway.oidc.id-token';
let activeCompletion: Promise<{ accessToken: string; returnTo: string } | null> | null = null;
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
interface Discovery {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
end_session_endpoint?: string;
}
interface Transaction {
state: string;
nonce: string;
verifier: string;
returnTo: string;
createdAt: number;
}
const callbackErrors: Record<string, string> = {
OIDC_LOGIN_INVALID: '统一认证登录事务无效或已过期,请重新登录',
OIDC_TOKEN_EXCHANGE_FAILED: '统一认证登录结果无效,请重新登录',
OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试',
GATEWAY_USER_NOT_PROVISIONED: '该账号尚未开通 EasyAI Gateway',
GATEWAY_USER_DISABLED: '该 Gateway 账号已停用,请联系管理员',
GATEWAY_TENANT_UNAVAILABLE: 'Gateway 租户尚未就绪,请联系管理员',
};
export function oidcLoginEnabled() {
return enabled && Boolean(issuer && clientId);
return enabled;
}
export function oidcBrowserSessionEnabled() {
@ -31,131 +20,30 @@ export function oidcBrowserSessionEnabled() {
}
export async function startOIDCLogin() {
assertConfigured();
const discovery = await getDiscovery();
const verifier = randomValue(48);
const transaction: Transaction = {
state: randomValue(24),
nonce: randomValue(24),
verifier,
returnTo: window.location.pathname,
createdAt: Date.now(),
};
window.sessionStorage.setItem(transactionKey, JSON.stringify(transaction));
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
const params = new URLSearchParams({
response_type: 'code', client_id: clientId, redirect_uri: redirectURI(),
scope: 'openid profile', state: transaction.state, nonce: transaction.nonce,
code_challenge: challenge, code_challenge_method: 'S256',
});
window.location.assign(`${discovery.authorization_endpoint}?${params}`);
}
export function completeOIDCLogin(): Promise<{ accessToken: string; returnTo: string } | null> {
if (activeCompletion) return activeCompletion;
activeCompletion = completeOIDCLoginOnce().finally(() => {
activeCompletion = null;
});
return activeCompletion;
}
async function completeOIDCLoginOnce(): Promise<{ accessToken: string; returnTo: string } | null> {
if (!oidcLoginEnabled()) return null;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (!code && !state) return null;
if (params.get('error')) throw new Error(`统一认证登录被拒绝:${params.get('error')}`);
if (!code || !state) throw new Error('统一认证回调不完整');
const transaction = readTransaction();
window.sessionStorage.removeItem(transactionKey);
if (!transaction || transaction.state !== state || Date.now() - transaction.createdAt > 10 * 60_000) {
throw new Error('统一认证登录事务已失效');
}
const discovery = await getDiscovery();
const response = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', client_id: clientId, redirect_uri: redirectURI(),
code, code_verifier: transaction.verifier,
}),
});
if (!response.ok) throw new Error('统一认证 Token 交换失败');
const payload = await response.json() as { access_token?: string; id_token?: string };
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.history.replaceState({}, '', transaction.returnTo || '/');
return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' };
if (!oidcLoginEnabled()) throw new Error('统一认证未配置');
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`);
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
window.location.assign(loginURL.toString());
}
export async function startOIDCLogout() {
if (!oidcLoginEnabled()) return false;
const idToken = window.sessionStorage.getItem(idTokenKey);
window.sessionStorage.removeItem(idTokenKey);
window.sessionStorage.removeItem(transactionKey);
const discovery = await getDiscovery();
if (!discovery.end_session_endpoint) return false;
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}`);
const form = document.createElement('form');
form.method = 'POST';
form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`;
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
return true;
}
function assertConfigured() {
if (!oidcLoginEnabled()) throw new Error('统一认证未配置');
}
async function getDiscovery(): Promise<Discovery> {
const response = await fetch(`${issuer}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('统一认证 Discovery 不可用');
const value = await response.json() as Discovery;
if (value.issuer !== issuer || !value.authorization_endpoint || !value.token_endpoint) {
throw new Error('统一认证 Discovery 不符合预期');
}
return value;
}
function redirectURI() {
return configuredRedirect || `${window.location.origin}/auth/callback`;
}
function readTransaction(): Transaction | null {
try {
return JSON.parse(window.sessionStorage.getItem(transactionKey) ?? 'null') as Transaction | null;
} catch {
return null;
}
}
function validateIDToken(raw: string, expectedNonce: string) {
const parts = raw.split('.');
if (parts.length !== 3) throw new Error('统一认证 ID Token 格式无效');
let claims: { iss?: string; aud?: string | string[]; nonce?: string; exp?: number };
try {
claims = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]))) as typeof claims;
} catch {
throw new Error('统一认证 ID Token 无法解析');
}
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (claims.iss !== issuer || !audiences.includes(clientId) || claims.nonce !== expectedNonce || !claims.exp || claims.exp * 1000 <= Date.now()) {
throw new Error('统一认证 ID Token 声明校验失败');
}
}
function randomValue(bytes: number) {
const value = new Uint8Array(bytes);
crypto.getRandomValues(value);
return base64url(value.buffer);
}
function base64url(value: ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(value)))
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
function base64urlDecode(value: string) {
const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - value.length % 4) % 4);
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
export function consumeOIDCCallbackError() {
const params = new URLSearchParams(window.location.search);
const code = params.get('oidcError') ?? '';
if (!code) return '';
params.delete('oidcError');
const query = params.toString();
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
return callbackErrors[code] ?? '统一认证登录失败,请重试';
}

View File

@ -18,6 +18,13 @@ x-api-environment: &api-environment
OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-}
OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true}
OIDC_SESSION_COOKIE_SECURE: ${OIDC_SESSION_COOKIE_SECURE:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
OIDC_POST_LOGOUT_REDIRECT_URI: ${OIDC_POST_LOGOUT_REDIRECT_URI:-}
OIDC_SESSION_ENCRYPTION_KEY: ${OIDC_SESSION_ENCRYPTION_KEY:-}
OIDC_SESSION_IDLE_TTL_SECONDS: ${OIDC_SESSION_IDLE_TTL_SECONDS:-1800}
OIDC_SESSION_ABSOLUTE_TTL_SECONDS: ${OIDC_SESSION_ABSOLUTE_TTL_SECONDS:-28800}
OIDC_SESSION_REFRESH_BEFORE_SECONDS: ${OIDC_SESSION_REFRESH_BEFORE_SECONDS:-60}
SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000}
SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me}
SERVER_MAIN_INTERNAL_KEY: ${SERVER_MAIN_INTERNAL_KEY:-gateway}
@ -28,6 +35,7 @@ x-api-environment: &api-environment
TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS: ${TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS:-10}
CORS_ALLOWED_ORIGIN: ${AI_GATEWAY_COMPOSE_CORS_ALLOWED_ORIGIN:-http://localhost:5178,http://127.0.0.1:5178}
AI_GATEWAY_PUBLIC_BASE_URL: ${AI_GATEWAY_COMPOSE_PUBLIC_BASE_URL:-http://localhost:8088}
AI_GATEWAY_WEB_BASE_URL: ${AI_GATEWAY_COMPOSE_WEB_BASE_URL:-http://localhost:5178}
AI_GATEWAY_GENERATED_STORAGE_DIR: /app/data/static/generated
AI_GATEWAY_UPLOADED_STORAGE_DIR: /app/data/static/uploaded
@ -112,9 +120,6 @@ services:
NODE_BUILD_IMAGE: ${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine}
WEB_RUNTIME_IMAGE: ${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine}
VITE_OIDC_ENABLED: ${OIDC_ENABLED:-false}
VITE_OIDC_ISSUER: ${OIDC_ISSUER:-}
VITE_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
VITE_OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
VITE_OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true}
VITE_BASE_PATH: ${AI_GATEWAY_WEB_BASE_PATH:-/}
ports:

View File

@ -14,6 +14,13 @@ OIDC_JIT_PROVISIONING_ENABLED=true
OIDC_GATEWAY_TENANT_KEY=default
OIDC_BROWSER_SESSION_ENABLED=true
OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP生产必须为 true
OIDC_CLIENT_ID=<Control Plane 生成的公共 Client ID>
OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/
OIDC_SESSION_ENCRYPTION_KEY=<独立的 32 字节随机密钥base64 编码>
OIDC_SESSION_IDLE_TTL_SECONDS=1800
OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800
OIDC_SESSION_REFRESH_BEFORE_SECONDS=60
```
- `OIDC_JIT_PROVISIONING_ENABLED` 默认 `false`。关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。
@ -22,14 +29,15 @@ OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP生产必须为 true
## Web Console 浏览器会话
- OIDC 回调仍使用 Authorization Code + PKCE 从 Auth Center 获取 Access Token但 Token 只在回调函数内存中短暂存在。
- Web 随即调用 `POST /api/v1/auth/oidc/session`Gateway 再次完成全部 OIDC 校验后,将原 Auth Center Access Token 写入 `HttpOnly + SameSite=Strict` Cookie不签发第二枚 Gateway JWT。
- Cookie 有效期不超过 Access Token 的 `exp`,所有 Cookie 请求仍经过 JWKS、Issuer、Audience、`tid`、Scope、角色及可选 Introspection 校验。
- 建立成功后清除旧的 OIDC `sessionStorage` 和可能冲突的 `localStorage` Token新标签页通过 Cookie 调用 `/api/v1/me` 自动恢复同一登录态。
- Web 只跳转 `GET /api/v1/auth/oidc/login`。Gateway 生成 state、nonce、PKCE verifier并以 Authorization Code + PKCE S256 完成换码;公共 Client 请求只发送 `client_id`,不使用 Client Secret。
- Access Token、Refresh Token 和 ID Token 使用 AES-256-GCM 加密后存入 PostgreSQL数据库只保存随机 Session Cookie 的 SHA-256。浏览器 JavaScript、响应体和 Web Storage 均不接触 Token。
- 浏览器只保存 32 字节随机、`HttpOnly + SameSite=Strict` 的 Session Cookie。新标签页通过该 Cookie 调用 `/api/v1/me` 恢复登录态Gateway 不签发第二枚 JWT。
- Access Token 继续保持 5 分钟。认证请求发现剩余时间不超过 60 秒时使用 Refresh Token 自动刷新;多实例通过 PostgreSQL 刷新租约保证同一版本只刷新一次,并原子保存旋转后的 Refresh Token。
- Session 闲置 30 分钟、绝对最长 8 小时。闲置 530 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。
- 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权的 POST、PUT、PATCH、DELETE 必须携带 `CORS_ALLOWED_ORIGIN` 白名单中的 Origin否则返回结构化 403。
- Staging、生产及其他非本地环境启动时强制 `OIDC_SESSION_COOKIE_SECURE=true`,并拒绝带 `*` 的凭据型 CORS 配置。本地 HTTP 开发和自动化测试可以显式设为 `false`
- `DELETE /api/v1/auth/oidc/session` 使 Cookie 立即过期,然后前端进入 Auth Center OIDC 登出。浏览器会话关闭时不会创建或撤销 Gateway API Key
- 回滚时同时设置后端 `OIDC_BROWSER_SESSION_ENABLED=false` 和 Web 构建参数 `VITE_OIDC_BROWSER_SESSION_ENABLED=false`,恢复旧的标签页级 `sessionStorage` 行为
- `POST /api/v1/auth/oidc/logout` 校验可信 Origin、删除本地 Session、使用公共 Client ID 撤销 Refresh Token并跳转 Auth Center 退出地址;`DELETE /api/v1/auth/oidc/session` 保留为幂等的本地删除接口
- `OIDC_SESSION_ENCRYPTION_KEY` 必须来自 Git 忽略的本地文件、Kubernetes Secret 或 Secret Manager不得复用 JWT Secret。关闭 `OIDC_BROWSER_SESSION_ENABLED` 可停止新会话;回滚镜像后已创建记录作为惰性数据保留
## 数据和事务语义
@ -50,8 +58,10 @@ OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP生产必须为 true
| 503 | `GATEWAY_TENANT_UNAVAILABLE` | 配置租户或用户组不存在/未启用 |
| 503 | `GATEWAY_USER_PROVISIONING_FAILED` | 事务或存储故障;响应不暴露内部错误 |
| 404 | `OIDC_BROWSER_SESSION_DISABLED` | Gateway 浏览器会话功能未启用 |
| 401 | `OIDC_SESSION_INVALID` | 不是有效的 Auth Center OIDC Access Token 或已过期 |
| 400 | `OIDC_SESSION_TOKEN_TOO_LARGE` | Access Token 超过安全 Cookie 大小限制 |
| 401 | `OIDC_SESSION_INVALID` | Session 不存在、Cookie 格式错误或 Token 身份不匹配 |
| 401 | `OIDC_SESSION_EXPIRED` | 闲置/绝对期限已到或 Refresh Token 已失效 |
| 503 | `OIDC_SESSION_REFRESH_UNAVAILABLE` | Access Token 已过期且认证中心暂时不可用 |
| 503 | `OIDC_SESSION_STORE_UNAVAILABLE` | 数据库或 Token 密文不可用 |
| 403 | `OIDC_SESSION_CSRF_REJECTED` | Cookie 写请求缺少可信 Origin |
`ErrLocalUserRequired` 仅作为防御性内部错误保留,对外统一转换为结构化 403。

View File

@ -22,9 +22,6 @@ load_local_env() {
done
export VITE_OIDC_ENABLED="${VITE_OIDC_ENABLED:-${OIDC_ENABLED:-false}}"
export VITE_OIDC_ISSUER="${VITE_OIDC_ISSUER:-${OIDC_ISSUER:-}}"
export VITE_OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-}}"
export VITE_OIDC_REDIRECT_URI="${VITE_OIDC_REDIRECT_URI:-${OIDC_REDIRECT_URI:-}}"
export VITE_OIDC_BROWSER_SESSION_ENABLED="${VITE_OIDC_BROWSER_SESSION_ENABLED:-${OIDC_BROWSER_SESSION_ENABLED:-true}}"
}