feat: add stable OIDC authentication
This commit is contained in:
+28
-1
@@ -107,6 +107,7 @@ 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 { runTask } from './lib/run-task';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
@@ -264,6 +265,18 @@ export function App() {
|
||||
currentTaskQueryKeyRef.current = taskListRequestKey;
|
||||
currentTransactionQueryKeyRef.current = transactionListRequestKey;
|
||||
|
||||
useEffect(() => {
|
||||
void completeOIDCLogin()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
persistAccessToken(result.accessToken);
|
||||
setToken(result.accessToken);
|
||||
})
|
||||
.catch((err) => {
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void ensureData(['health']);
|
||||
}, []);
|
||||
@@ -1064,11 +1077,21 @@ export function App() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
async function signOut() {
|
||||
resetAuthenticatedSession();
|
||||
if (await startOIDCLogout()) return;
|
||||
navigatePath('/');
|
||||
}
|
||||
|
||||
function loginWithOIDC() {
|
||||
setState('loading');
|
||||
setError('');
|
||||
void startOIDCLogin().catch((err) => {
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||
});
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
setAuthMode('login');
|
||||
navigatePath(pathForWorkspaceSection('overview'));
|
||||
@@ -1197,6 +1220,8 @@ export function App() {
|
||||
onSubmitExternalToken={submitExternalToken}
|
||||
onSubmitLogin={submitLogin}
|
||||
onSubmitRegister={submitRegister}
|
||||
oidcEnabled={oidcLoginEnabled()}
|
||||
onOIDCLogin={loginWithOIDC}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -1254,6 +1279,8 @@ export function App() {
|
||||
onSubmitExternalToken={submitExternalToken}
|
||||
onSubmitLogin={submitLogin}
|
||||
onSubmitRegister={submitRegister}
|
||||
oidcEnabled={oidcLoginEnabled()}
|
||||
onOIDCLogin={loginWithOIDC}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,8 @@ export function AuthPanel(props: {
|
||||
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
|
||||
oidcEnabled: boolean;
|
||||
onOIDCLogin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="authShell" aria-label="登录">
|
||||
@@ -33,6 +35,12 @@ export function AuthPanel(props: {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="authContent">
|
||||
{props.oidcEnabled && (
|
||||
<Button type="button" disabled={props.state === 'loading'} onClick={props.onOIDCLogin}>
|
||||
<LogIn size={15} />
|
||||
使用统一认证中心登录
|
||||
</Button>
|
||||
)}
|
||||
<Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} />
|
||||
{props.authMode === 'login' && <LoginFormView {...props} />}
|
||||
{props.authMode === 'register' && <RegisterFormView {...props} />}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
|
||||
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 ?? '';
|
||||
const transactionKey = 'easyai.gateway.oidc.transaction';
|
||||
const idTokenKey = 'easyai.gateway.oidc.id-token';
|
||||
|
||||
interface Discovery {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
state: string;
|
||||
nonce: string;
|
||||
verifier: string;
|
||||
returnTo: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function oidcLoginEnabled() {
|
||||
return enabled && Boolean(issuer && clientId);
|
||||
}
|
||||
|
||||
export async function startOIDCLogin() {
|
||||
assertConfigured();
|
||||
const discovery = await getDiscovery();
|
||||
const verifier = randomValue(48);
|
||||
const transaction: Transaction = {
|
||||
state: randomValue(24),
|
||||
nonce: randomValue(24),
|
||||
verifier,
|
||||
returnTo: window.location.pathname,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
window.sessionStorage.setItem(transactionKey, JSON.stringify(transaction));
|
||||
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code', client_id: clientId, redirect_uri: redirectURI(),
|
||||
scope: 'openid profile', state: transaction.state, nonce: transaction.nonce,
|
||||
code_challenge: challenge, code_challenge_method: 'S256',
|
||||
});
|
||||
window.location.assign(`${discovery.authorization_endpoint}?${params}`);
|
||||
}
|
||||
|
||||
export async function completeOIDCLogin(): Promise<{ accessToken: string; returnTo: string } | null> {
|
||||
if (!oidcLoginEnabled()) return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (!code && !state) return null;
|
||||
if (!code || !state || params.get('error')) throw new Error('统一认证回调不完整');
|
||||
const transaction = readTransaction();
|
||||
window.sessionStorage.removeItem(transactionKey);
|
||||
if (!transaction || transaction.state !== state || Date.now() - transaction.createdAt > 10 * 60_000) {
|
||||
throw new Error('统一认证登录事务已失效');
|
||||
}
|
||||
const discovery = await getDiscovery();
|
||||
const response = await fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code', client_id: clientId, redirect_uri: redirectURI(),
|
||||
code, code_verifier: transaction.verifier,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('统一认证 Token 交换失败');
|
||||
const payload = await response.json() as { access_token?: string; id_token?: string };
|
||||
if (!payload.access_token) throw new Error('统一认证未返回 Access Token');
|
||||
if (payload.id_token) window.sessionStorage.setItem(idTokenKey, payload.id_token);
|
||||
window.history.replaceState({}, '', transaction.returnTo || '/');
|
||||
return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' };
|
||||
}
|
||||
|
||||
export async function startOIDCLogout() {
|
||||
if (!oidcLoginEnabled()) return false;
|
||||
const idToken = window.sessionStorage.getItem(idTokenKey);
|
||||
window.sessionStorage.removeItem(idTokenKey);
|
||||
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 + '/' });
|
||||
window.location.assign(`${discovery.end_session_endpoint}?${params}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertConfigured() {
|
||||
if (!oidcLoginEnabled()) throw new Error('统一认证未配置');
|
||||
}
|
||||
|
||||
async function getDiscovery(): Promise<Discovery> {
|
||||
const response = await fetch(`${issuer}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('统一认证 Discovery 不可用');
|
||||
const value = await response.json() as Discovery;
|
||||
if (value.issuer !== issuer || !value.authorization_endpoint || !value.token_endpoint) {
|
||||
throw new Error('统一认证 Discovery 不符合预期');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function redirectURI() {
|
||||
return configuredRedirect || `${window.location.origin}/auth/callback`;
|
||||
}
|
||||
|
||||
function readTransaction(): Transaction | null {
|
||||
try {
|
||||
return JSON.parse(window.sessionStorage.getItem(transactionKey) ?? 'null') as Transaction | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function randomValue(bytes: number) {
|
||||
const value = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(value);
|
||||
return base64url(value.buffer);
|
||||
}
|
||||
|
||||
function base64url(value: ArrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
||||
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
port: 5178,
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), 'VITE_');
|
||||
return {
|
||||
base: env.VITE_BASE_PATH || '/',
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: { port: 5178 },
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user