fix: 修复 OIDC 用户预配与跨标签页登录态
增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
+80
-14
@@ -50,6 +50,7 @@ import {
|
||||
deleteApiKey,
|
||||
deleteFileStorageChannel,
|
||||
deleteGatewayUser,
|
||||
deleteOIDCBrowserSession,
|
||||
deletePlatform,
|
||||
deleteTenant,
|
||||
deleteUserGroup,
|
||||
@@ -87,6 +88,7 @@ import {
|
||||
listUserGroups,
|
||||
listUsers,
|
||||
loginLocalAccount,
|
||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||
pollTaskUntilSettled,
|
||||
registerLocalAccount,
|
||||
rechargeUserWalletBalance,
|
||||
@@ -110,8 +112,20 @@ import { LoginRequiredPanel } from './components/LoginRequiredPanel';
|
||||
import { useCatalogOperations } from './hooks/useCatalogOperations';
|
||||
import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations';
|
||||
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
|
||||
import { persistAccessToken, readStoredAccessToken } from './lib/auth-storage';
|
||||
import { completeOIDCLogin, oidcLoginEnabled, startOIDCLogin, startOIDCLogout } from './lib/oidc';
|
||||
import {
|
||||
persistAccessToken,
|
||||
persistLegacyOIDCAccessToken,
|
||||
readLegacyOIDCAccessToken,
|
||||
readStoredAccessToken,
|
||||
} from './lib/auth-storage';
|
||||
import { activateOIDCBrowserSession, restoreOIDCBrowserSession } from './lib/oidc-browser-session';
|
||||
import {
|
||||
completeOIDCLogin,
|
||||
oidcBrowserSessionEnabled,
|
||||
oidcLoginEnabled,
|
||||
startOIDCLogin,
|
||||
startOIDCLogout,
|
||||
} from './lib/oidc';
|
||||
import { runTask } from './lib/run-task';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
@@ -272,17 +286,45 @@ export function App() {
|
||||
currentTransactionQueryKeyRef.current = transactionListRequestKey;
|
||||
|
||||
useEffect(() => {
|
||||
void completeOIDCLogin()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
persistAccessToken(result.accessToken, 'session');
|
||||
setToken(result.accessToken);
|
||||
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));
|
||||
})
|
||||
.catch((err) => {
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||
});
|
||||
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);
|
||||
})().catch((err) => {
|
||||
if (cancelled) return;
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void ensureData(['health']);
|
||||
@@ -402,6 +444,7 @@ export function App() {
|
||||
try {
|
||||
await Promise.all(requestKeys.map((key) => loadDataKey(key, nextToken)));
|
||||
requestKeys.forEach((key) => loadedDataKeysRef.current.add(key));
|
||||
setError('');
|
||||
setState('ready');
|
||||
} catch (err) {
|
||||
if (handleAuthExpired(err, nextToken)) return;
|
||||
@@ -1026,7 +1069,9 @@ export function App() {
|
||||
const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : '';
|
||||
const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id;
|
||||
const credential = selectedApiKeySecret || (fallbackApiKeySecret ? apiKeySecretsById[fallbackApiKeySecret] : '') || apiKeySecret || token;
|
||||
const credentialLabel = selectedApiKeySecret || fallbackApiKeySecret || apiKeySecret ? '本地 API Key' : '当前 Access Token';
|
||||
const usingLocalAPIKey = Boolean(selectedApiKeySecret || fallbackApiKeySecret || apiKeySecret);
|
||||
let credentialLabel = usingLocalAPIKey ? '本地 API Key' : '当前 Access Token';
|
||||
if (!usingLocalAPIKey && token === OIDC_BROWSER_SESSION_CREDENTIAL) credentialLabel = '当前登录会话';
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
@@ -1104,8 +1149,28 @@ 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 (await startOIDCLogout()) return;
|
||||
if (shouldEndOIDCSession) {
|
||||
try {
|
||||
if (await startOIDCLogout()) return;
|
||||
} catch {
|
||||
navigatePath('/');
|
||||
setState('error');
|
||||
setError('Gateway 会话已注销,但统一认证退出失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
navigatePath('/');
|
||||
}
|
||||
|
||||
@@ -1137,6 +1202,7 @@ export function App() {
|
||||
}
|
||||
|
||||
function navigatePath(path: string) {
|
||||
setError('');
|
||||
if (`${window.location.pathname}${window.location.search}` !== path) {
|
||||
window.history.pushState(null, '', path);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createOIDCBrowserSession,
|
||||
deleteOIDCBrowserSession,
|
||||
GatewayApiError,
|
||||
gatewayErrorMessage,
|
||||
getCurrentUser,
|
||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||
} from './api';
|
||||
|
||||
describe('Gateway provisioning errors', () => {
|
||||
const cases = [
|
||||
['GATEWAY_USER_NOT_PROVISIONED', '该账号尚未开通 EasyAI Gateway'],
|
||||
['GATEWAY_USER_DISABLED', '该 Gateway 账号已停用,请联系管理员'],
|
||||
['GATEWAY_TENANT_UNAVAILABLE', 'Gateway 租户尚未就绪,请联系管理员'],
|
||||
['GATEWAY_USER_PROVISIONING_FAILED', 'Gateway 账号初始化失败,请稍后重试'],
|
||||
['OIDC_BROWSER_SESSION_DISABLED', 'Gateway 浏览器会话尚未启用'],
|
||||
['OIDC_SESSION_INVALID', '统一认证会话无效,请重新登录'],
|
||||
['OIDC_SESSION_TOKEN_TOO_LARGE', '统一认证凭证过大,无法建立浏览器会话'],
|
||||
['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'],
|
||||
] as const;
|
||||
|
||||
for (const [code, expected] of cases) {
|
||||
it(`maps ${code} to a stable Chinese status`, () => {
|
||||
expect(gatewayErrorMessage({ code, message: 'internal server message', status: 503 })).toBe(expected);
|
||||
expect(new GatewayApiError({ code, message: 'internal server message', status: 503 }).message).toContain(expected);
|
||||
expect(new GatewayApiError({ code, message: 'internal server message', status: 503 }).message).not.toContain('internal server message');
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps the server message for unrelated errors', () => {
|
||||
expect(gatewayErrorMessage({ code: 'OTHER_ERROR', message: '模型不可用', status: 503 })).toBe('模型不可用');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC browser session transport', () => {
|
||||
afterEach(() => {
|
||||
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,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(new Headers(init.headers).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes the shared cookie with a credentialed request', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await deleteOIDCBrowserSession();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(new Headers(init.headers).has('Authorization')).toBe(false);
|
||||
});
|
||||
});
|
||||
+44
-7
@@ -51,10 +51,12 @@ import type {
|
||||
WalletSummaryResponse,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
||||
import { oidcBrowserSessionEnabled } from './lib/oidc';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
||||
export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__';
|
||||
|
||||
interface GatewayErrorDetails {
|
||||
export interface GatewayErrorDetails {
|
||||
code?: string;
|
||||
message: string;
|
||||
requestId?: string;
|
||||
@@ -110,6 +112,20 @@ 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,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(token: string): Promise<AuthUser> {
|
||||
return request<AuthUser>('/api/v1/me', { token });
|
||||
}
|
||||
@@ -631,9 +647,10 @@ export async function* streamChatCompletionText(
|
||||
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
|
||||
body: JSON.stringify({ ...input, stream: true }),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...authorizationHeader(token),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
@@ -834,9 +851,8 @@ export async function uploadFileToStorage(
|
||||
|
||||
const response = await fetch(`${API_BASE}/v1/files/upload`, {
|
||||
body: form,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
|
||||
headers: authorizationHeader(token),
|
||||
method: 'POST',
|
||||
});
|
||||
const body = await response.text();
|
||||
@@ -1030,7 +1046,7 @@ async function request<T>(
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
||||
if (options.auth !== false && options.token) {
|
||||
if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) {
|
||||
headers.Authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
if (options.body !== undefined) {
|
||||
@@ -1040,6 +1056,7 @@ async function request<T>(
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
@@ -1051,6 +1068,11 @@ async function request<T>(
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function authorizationHeader(token: string): Record<string, string> {
|
||||
if (!token || token === OIDC_BROWSER_SESSION_CREDENTIAL) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1122,7 +1144,7 @@ function errorDetailsFromParsed(parsed: unknown, status?: number, fallback = '')
|
||||
}
|
||||
|
||||
function formatGatewayErrorDetails(details: GatewayErrorDetails) {
|
||||
const message = details.message || '请求失败';
|
||||
const message = gatewayErrorMessage(details);
|
||||
const meta = [
|
||||
details.code ? `错误码: ${details.code}` : '',
|
||||
details.status ? `状态: ${details.status}` : '',
|
||||
@@ -1132,6 +1154,21 @@ function formatGatewayErrorDetails(details: GatewayErrorDetails) {
|
||||
return meta.length ? `${message}(${meta.join(',')})` : message;
|
||||
}
|
||||
|
||||
const gatewayProvisioningErrorMessages: Record<string, string> = {
|
||||
GATEWAY_USER_NOT_PROVISIONED: '该账号尚未开通 EasyAI Gateway',
|
||||
GATEWAY_USER_DISABLED: '该 Gateway 账号已停用,请联系管理员',
|
||||
GATEWAY_TENANT_UNAVAILABLE: 'Gateway 租户尚未就绪,请联系管理员',
|
||||
GATEWAY_USER_PROVISIONING_FAILED: 'Gateway 账号初始化失败,请稍后重试',
|
||||
OIDC_BROWSER_SESSION_DISABLED: 'Gateway 浏览器会话尚未启用',
|
||||
OIDC_SESSION_INVALID: '统一认证会话无效,请重新登录',
|
||||
OIDC_SESSION_TOKEN_TOO_LARGE: '统一认证凭证过大,无法建立浏览器会话',
|
||||
OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试',
|
||||
};
|
||||
|
||||
export function gatewayErrorMessage(details: GatewayErrorDetails) {
|
||||
return (details.code && gatewayProvisioningErrorMessages[details.code]) || details.message || '请求失败';
|
||||
}
|
||||
|
||||
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
|
||||
@@ -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