feat: 将 Gateway Web 迁移到 BFF 会话

浏览器只通过 HttpOnly Cookie 恢复统一认证状态,不再访问认证中心 Token Endpoint 或保存 OIDC Token。同步更新错误提示、部署配置和接入文档。
This commit is contained in:
2026-07-13 19:09:57 +08:00
parent c5c82bb528
commit 46bac51703
15 changed files with 143 additions and 287 deletions
+29 -141
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] ?? '统一认证登录失败,请重试';
}