fix: 为失效 OIDC 事务提供安全重试

细分回调事务、状态和授权响应错误,提供脱敏诊断编号与显式重新登录入口,避免失效事务形成重试循环。
This commit is contained in:
2026-07-13 22:50:09 +08:00
parent 6e0a5fe397
commit 8ca68eb3cd
8 changed files with 314 additions and 15 deletions
+8 -3
View File
@@ -108,6 +108,7 @@ import {
import type { ConsoleData, StatItem } from './app-state';
import { AppShell } from './components/layout/AppShell';
import { LoginRequiredPanel } from './components/LoginRequiredPanel';
import { OIDCCallbackNotice } from './components/OIDCCallbackNotice';
import { useCatalogOperations } from './hooks/useCatalogOperations';
import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations';
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
@@ -306,7 +307,7 @@ export function App() {
};
}, [oidcCallbackError]);
useEffect(() => {
if (token) setOIDCCallbackError('');
if (token) setOIDCCallbackError(null);
}, [token]);
useEffect(() => {
void ensureData(['health']);
@@ -1152,7 +1153,7 @@ export function App() {
function loginWithOIDC() {
setState('loading');
setError('');
setOIDCCallbackError('');
setOIDCCallbackError(null);
void startOIDCLogin().catch((err) => {
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
@@ -1231,7 +1232,11 @@ export function App() {
onRefresh={() => void refresh()}
onSignOut={signOut}
>
{(oidcCallbackError || error) && <div className="notice">{oidcCallbackError || error}</div>}
{oidcCallbackError ? (
<OIDCCallbackNotice error={oidcCallbackError} onRetry={loginWithOIDC} />
) : error ? (
<div className="notice">{error}</div>
) : null}
{activePage === 'home' && <HomePage onNavigate={navigatePage} onPlaygroundMode={navigatePlaygroundMode} />}
{activePage === 'playground' && (
<PlaygroundPage
@@ -0,0 +1,48 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { OIDCCallbackNotice } from './OIDCCallbackNotice';
describe('OIDCCallbackNotice', () => {
it('renders a one-click fresh login action for an invalid transaction', () => {
const html = renderToStaticMarkup(
<OIDCCallbackNotice
error={{ code: 'OIDC_LOGIN_INVALID', message: '登录事务已过期', retryable: true }}
onRetry={vi.fn()}
/>,
);
expect(html).toContain('登录事务已过期');
expect(html).toContain('>重新登录</button>');
});
it('does not retry non-transaction callback failures', () => {
const html = renderToStaticMarkup(
<OIDCCallbackNotice
error={{ code: 'OIDC_TOKEN_EXCHANGE_FAILED', message: '登录结果无效', retryable: false }}
onRetry={vi.fn()}
/>,
);
expect(html).toContain('登录结果无效');
expect(html).not.toContain('<button');
});
it('shows a safe diagnostic id without exposing callback parameters', () => {
const html = renderToStaticMarkup(
<OIDCCallbackNotice
error={{
code: 'OIDC_LOGIN_INVALID',
reason: 'STATE_MISMATCH',
diagnosticId: 'diag-123',
message: '认证回调与当前登录事务不匹配',
retryable: true,
}}
onRetry={vi.fn()}
/>,
);
expect(html).toContain('诊断编号:diag-123');
expect(html).not.toContain('state=');
expect(html).not.toContain('code=');
});
});
@@ -0,0 +1,21 @@
import type { OIDCCallbackError } from '../lib/oidc';
import { Button } from './ui';
export function OIDCCallbackNotice(props: {
error: OIDCCallbackError;
onRetry: () => void;
}) {
return (
<div className="notice oidcCallbackNotice" role="alert">
<span className="oidcCallbackMessage">
<span>{props.error.message}</span>
{props.error.diagnosticId && <small>{props.error.diagnosticId}</small>}
</span>
{props.error.retryable && (
<Button type="button" variant="outline" size="sm" onClick={props.onRetry}>
</Button>
)}
</div>
);
}
+55
View File
@@ -37,4 +37,59 @@ describe('OIDC BFF navigation', () => {
expect(Object.keys(form)).not.toContain('accessToken');
expect(submit).toHaveBeenCalledOnce();
});
it('turns an invalid login transaction callback into an explicit retry action', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' },
history: { replaceState },
});
const { consumeOIDCCallbackError } = await import('./oidc');
expect(consumeOIDCCallbackError()).toEqual({
code: 'OIDC_LOGIN_INVALID',
message: '统一认证登录事务无效或已过期,请重新登录',
retryable: true,
});
expect(replaceState).toHaveBeenCalledWith({}, '', '/#top');
});
it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: {
pathname: '/',
search: '?tab=login&oidcError=OIDC_LOGIN_INVALID&oidcErrorReason=COOKIE_MISSING&oidcDiagnosticId=diag-123',
hash: '',
},
history: { replaceState },
});
const { consumeOIDCCallbackError } = await import('./oidc');
expect(consumeOIDCCallbackError()).toEqual({
code: 'OIDC_LOGIN_INVALID',
reason: 'COOKIE_MISSING',
diagnosticId: 'diag-123',
message: '浏览器未携带统一认证登录事务 Cookie,请允许 localhost Cookie 后重新登录',
retryable: true,
});
expect(replaceState).toHaveBeenCalledWith({}, '', '/?tab=login');
});
it('does not offer transaction retry for a token exchange failure', async () => {
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
vi.stubGlobal('window', {
location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' },
history: { replaceState: vi.fn() },
});
const { consumeOIDCCallbackError } = await import('./oidc');
expect(consumeOIDCCallbackError()).toEqual({
code: 'OIDC_TOKEN_EXCHANGE_FAILED',
message: '统一认证登录结果无效,请重新登录',
retryable: false,
});
});
});
+37 -9
View File
@@ -2,13 +2,28 @@ const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
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 type OIDCCallbackError = {
code: string;
reason?: string;
diagnosticId?: string;
message: string;
retryable: boolean;
};
const callbackErrors: Record<string, Omit<OIDCCallbackError, 'code'>> = {
OIDC_LOGIN_INVALID: { message: '统一认证登录事务无效或已过期,请重新登录', retryable: true },
OIDC_TOKEN_EXCHANGE_FAILED: { message: '统一认证登录结果无效,请重新登录', retryable: false },
OIDC_SESSION_STORE_UNAVAILABLE: { message: '登录会话存储暂时不可用,请稍后重试', retryable: false },
GATEWAY_USER_NOT_PROVISIONED: { message: '该账号尚未开通 EasyAI Gateway', retryable: false },
GATEWAY_USER_DISABLED: { message: '该 Gateway 账号已停用,请联系管理员', retryable: false },
GATEWAY_TENANT_UNAVAILABLE: { message: 'Gateway 租户尚未就绪,请联系管理员', retryable: false },
};
const loginTransactionFailureMessages: Record<string, string> = {
COOKIE_MISSING: '浏览器未携带统一认证登录事务 Cookie,请允许 localhost Cookie 后重新登录',
TRANSACTION_INVALID: '统一认证登录事务无法验证或已过期,请重新登录',
STATE_MISMATCH: '认证回调与当前登录事务不匹配,请关闭重复登录页面后重新登录',
AUTHORIZATION_RESPONSE_MISSING: '认证中心回调缺少必要参数,请重新登录',
};
export function oidcLoginEnabled() {
@@ -41,9 +56,22 @@ export async function startOIDCLogout() {
export function consumeOIDCCallbackError() {
const params = new URLSearchParams(window.location.search);
const code = params.get('oidcError') ?? '';
if (!code) return '';
if (!code) return null;
const rawReason = params.get('oidcErrorReason') ?? '';
const reason = code === 'OIDC_LOGIN_INVALID' && loginTransactionFailureMessages[rawReason] ? rawReason : '';
const rawDiagnosticId = params.get('oidcDiagnosticId') ?? '';
const diagnosticId = /^[A-Za-z0-9_-]{6,64}$/.test(rawDiagnosticId) ? rawDiagnosticId : '';
params.delete('oidcError');
params.delete('oidcErrorReason');
params.delete('oidcDiagnosticId');
const query = params.toString();
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
return callbackErrors[code] ?? '统一认证登录失败,请重试';
const callbackError = callbackErrors[code] ?? { message: '统一认证登录失败,请重试', retryable: false };
return {
code,
...(reason ? { reason } : {}),
...(diagnosticId ? { diagnosticId } : {}),
...callbackError,
...(reason ? { message: loginTransactionFailureMessages[reason] } : {}),
};
}
+18
View File
@@ -859,6 +859,24 @@ strong {
font-size: var(--font-size-sm);
}
.oidcCallbackNotice {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.oidcCallbackMessage {
display: grid;
gap: 4px;
}
.oidcCallbackMessage small {
color: inherit;
opacity: 0.72;
}
.statGrid {
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
}