feat: 将 Gateway Web 迁移到 BFF 会话
浏览器只通过 HttpOnly Cookie 恢复统一认证状态,不再访问认证中心 Token Endpoint 或保存 OIDC Token。同步更新错误提示、部署配置和接入文档。
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+29
-141
@@ -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] ?? '统一认证登录失败,请重试';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user