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