feat(identity): 收敛 AI Gateway 统一认证入口

Web 登录页合并为单一统一认证入口,未指定上下文时由认证中心在登录后决策。继续接受旧客户端显式传入 platform 或 tenant,并保持回调上下文及 tenant_hint 的绑定校验。同步更新 OpenAPI 产物。\n\n验证:Go 全量测试与 go vet 通过;Web lint、141 项 Vitest 和生产构建通过;本地 platform.admin 登录及管理工作台访问通过。
This commit is contained in:
2026-07-31 15:28:12 +08:00
parent c0296dbf06
commit 59f0817938
13 changed files with 186 additions and 69 deletions
+3 -12
View File
@@ -135,7 +135,6 @@ import {
loadOIDCRuntimeConfiguration,
startOIDCLogin,
startOIDCLogout,
type OIDCContextType,
} from './lib/oidc';
import { runTask, type RunTaskOptions } from './lib/run-task';
import { AdminPage } from './pages/AdminPage';
@@ -281,7 +280,6 @@ export function App() {
const [error, setError] = useState('');
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
const [oidcEnabled, setOIDCEnabled] = useState(false);
const [oidcContextTypes, setOIDCContextTypes] = useState<OIDCContextType[]>([]);
const currentCredentialRef = useRef(token);
const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined);
currentCredentialRef.current = token;
@@ -340,7 +338,6 @@ export function App() {
if (cancelled) return;
const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled);
setOIDCContextTypes(configuration.contextTypes);
if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) {
await reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: currentCredentialRef.current,
@@ -1307,14 +1304,11 @@ export function App() {
navigatePath('/');
}
function loginWithOIDC(
contextType: OIDCContextType =
oidcContextTypes.includes('platform') ? 'platform' : 'tenant',
) {
function loginWithOIDC() {
setState('loading');
setError('');
setOIDCCallbackError(null);
void startOIDCLogin(contextType).catch((err) => {
void startOIDCLogin().catch((err) => {
setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败');
});
@@ -1327,13 +1321,12 @@ export function App() {
const configuration = await loadOIDCRuntimeConfiguration(true);
const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled);
setOIDCContextTypes(configuration.contextTypes);
if (
loginEntryAction(identityEnabled) === 'oidc' &&
configuration.contextTypes.length === 1
) {
setOIDCCallbackError(null);
await startOIDCLogin(configuration.contextTypes[0], configuration);
await startOIDCLogin(undefined, configuration);
return;
}
setState('idle');
@@ -1492,7 +1485,6 @@ export function App() {
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
oidcContextTypes={oidcContextTypes}
onOIDCLogin={loginWithOIDC}
/>
)
@@ -1565,7 +1557,6 @@ export function App() {
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
oidcContextTypes={oidcContextTypes}
onOIDCLogin={loginWithOIDC}
/>
)
+4 -4
View File
@@ -15,16 +15,16 @@ const baseProps = {
onSubmitExternalToken: vi.fn(),
onSubmitLogin: vi.fn(),
onSubmitRegister: vi.fn(),
oidcContextTypes: ['platform', 'tenant'] as const,
onOIDCLogin: vi.fn(),
};
describe('AuthPanel', () => {
it('shows explicit platform and tenant entries when OIDC is enabled', () => {
it('shows one unified authentication entry when OIDC is enabled', () => {
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />);
expect(html).toContain('使用平台账号登录');
expect(html).toContain('使用租户账号登录');
expect(html).toContain('使用统一认证登录');
expect(html).not.toContain('使用平台账号登录');
expect(html).not.toContain('使用租户账号登录');
expect(html).not.toContain('用户名或邮箱');
expect(html).not.toContain('注册账号');
expect(html).not.toContain('外部 Token');
+9 -23
View File
@@ -2,7 +2,6 @@ import type { FormEvent } from 'react';
import { LogIn, UserPlus } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui';
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
import type { OIDCContextType } from '../lib/oidc';
const tabs = [
{ value: 'login', label: '账号登录' },
@@ -24,8 +23,7 @@ export function AuthPanel(props: {
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
oidcEnabled: boolean;
oidcContextTypes: readonly OIDCContextType[];
onOIDCLogin: (contextType: OIDCContextType) => void;
onOIDCLogin: () => void;
}) {
return (
<section className="authShell" aria-label="登录">
@@ -39,26 +37,14 @@ export function AuthPanel(props: {
<CardContent className="authContent">
{props.oidcEnabled ? (
<div className="formGrid">
{props.oidcContextTypes.includes('platform') && (
<Button
type="button"
disabled={props.state === 'loading'}
onClick={() => props.onOIDCLogin('platform')}
>
<LogIn size={15} />
使
</Button>
)}
{props.oidcContextTypes.includes('tenant') && (
<Button
type="button"
disabled={props.state === 'loading'}
onClick={() => props.onOIDCLogin('tenant')}
>
<LogIn size={15} />
使
</Button>
)}
<Button
type="button"
disabled={props.state === 'loading'}
onClick={props.onOIDCLogin}
>
<LogIn size={15} />
使
</Button>
</div>
) : (
<>
+3 -3
View File
@@ -7,7 +7,7 @@ describe('OIDC BFF navigation', () => {
vi.resetModules();
});
it('binds platform context and returnTo at the Gateway login endpoint', async () => {
it('lets the authentication center select context and binds returnTo', async () => {
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
@@ -18,11 +18,11 @@ describe('OIDC BFF navigation', () => {
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
});
const { startOIDCLogin } = await import('./oidc');
await startOIDCLogin('platform');
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.get('contextType')).toBe('platform');
expect(target.searchParams.has('contextType')).toBe(false);
expect(target.searchParams.has('client_id')).toBe(false);
expect(target.searchParams.has('code_challenge')).toBe(false);
});
+3 -3
View File
@@ -92,7 +92,7 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
}
export async function startOIDCLogin(
contextType: OIDCContextType,
contextType?: OIDCContextType,
configuration?: OIDCRuntimeConfiguration,
) {
configuration ??= await loadOIDCRuntimeConfiguration(true);
@@ -100,12 +100,12 @@ export async function startOIDCLogin(
!configuration.enabled ||
!configuration.oidcLogin ||
!configuration.loginUrl ||
!configuration.contextTypes.includes(contextType)
(contextType !== undefined && !configuration.contextTypes.includes(contextType))
) throw new Error('统一认证登录上下文未配置');
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
loginURL.searchParams.set('contextType', contextType);
if (contextType !== undefined) loginURL.searchParams.set('contextType', contextType);
window.location.assign(loginURL.toString());
}