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
+12 -7
View File
@@ -13,16 +13,21 @@ describe('auth storage', () => {
vi.stubGlobal('window', { localStorage: new MemoryStorage(), sessionStorage: new MemoryStorage() });
});
it('keeps OIDC access tokens in session storage and removes persistent tokens', () => {
persistAccessToken('legacy-token');
persistAccessToken('oidc-token', 'session');
expect(readStoredAccessToken()).toBe('oidc-token');
expect(window.localStorage.getItem('easyai_ai_gateway_access_token')).toBeNull();
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBe('oidc-token');
it('persists only explicit local or externally supplied bearer tokens', () => {
persistAccessToken('local-token');
expect(readStoredAccessToken()).toBe('local-token');
expect(window.localStorage.getItem('easyai_ai_gateway_access_token')).toBe('local-token');
expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull();
});
it('reads a legacy OIDC session token only for one-time cookie migration', () => {
window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'legacy-oidc-token');
expect(readStoredAccessToken()).toBe('legacy-oidc-token');
});
it('clears both token stores on logout', () => {
persistAccessToken('oidc-token', 'session');
window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'legacy-oidc-token');
persistAccessToken('local-token');
persistAccessToken('');
expect(readStoredAccessToken()).toBe('');
});
+21 -4
View File
@@ -12,15 +12,32 @@ export function readStoredAccessToken() {
}
}
export function persistAccessToken(value: string, storage: 'local' | 'session' = 'local') {
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 {
if (!value) {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY);
} else if (storage === 'session') {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.sessionStorage.setItem(OIDC_SESSION_TOKEN_STORAGE_KEY, value);
} else {
window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY);
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, value);
@@ -0,0 +1,47 @@
import { beforeEach, 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); }
}
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',
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const restored = await restoreOIDCBrowserSession();
expect(restored?.credential).toBe(OIDC_BROWSER_SESSION_CREDENTIAL);
expect(restored?.user.sub).toBe('platform-user');
});
it('treats a missing or expired shared cookie as a signed-out tab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: 'unauthorized', status: 401 },
}), { status: 401, headers: { 'Content-Type': 'application/json' } })));
await expect(restoreOIDCBrowserSession()).resolves.toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
import {
createOIDCBrowserSession,
GatewayApiError,
getCurrentUser,
OIDC_BROWSER_SESSION_CREDENTIAL,
} from '../api';
import { persistAccessToken } from './auth-storage';
type CurrentUser = Awaited<ReturnType<typeof getCurrentUser>>;
export interface RestoredOIDCBrowserSession {
credential: typeof OIDC_BROWSER_SESSION_CREDENTIAL;
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);
return { credential: OIDC_BROWSER_SESSION_CREDENTIAL, user };
} catch (error) {
if (error instanceof GatewayApiError && error.details.status === 401) return null;
throw error;
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
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 ?? '';
@@ -25,6 +26,10 @@ export function oidcLoginEnabled() {
return enabled && Boolean(issuer && clientId);
}
export function oidcBrowserSessionEnabled() {
return browserSessionEnabled;
}
export async function startOIDCLogin() {
assertConfigured();
const discovery = await getDiscovery();
@@ -81,7 +86,6 @@ async function completeOIDCLoginOnce(): Promise<{ accessToken: string; returnTo:
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.sessionStorage.setItem(idTokenKey, payload.id_token);
window.history.replaceState({}, '', transaction.returnTo || '/');
return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' };
}
@@ -91,10 +95,10 @@ export async function startOIDCLogout() {
const idToken = window.sessionStorage.getItem(idTokenKey);
window.sessionStorage.removeItem(idTokenKey);
window.sessionStorage.removeItem(transactionKey);
if (!idToken) return false;
const discovery = await getDiscovery();
if (!discovery.end_session_endpoint) return false;
const params = new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: window.location.origin + '/' });
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}`);
return true;
}