feat(gateway): 接入统一认证中心本地登录

This commit is contained in:
2026-07-12 20:45:35 +08:00
parent f8d766b916
commit 03abc0eab7
10 changed files with 123 additions and 15 deletions
+2 -1
View File
@@ -269,8 +269,9 @@ export function App() {
void completeOIDCLogin()
.then((result) => {
if (!result) return;
persistAccessToken(result.accessToken);
persistAccessToken(result.accessToken, 'session');
setToken(result.accessToken);
applyRoute(parseAppRoute(result.returnTo));
})
.catch((err) => {
setState('error');
+29
View File
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { persistAccessToken, readStoredAccessToken } from './auth-storage';
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('auth storage', () => {
beforeEach(() => {
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('clears both token stores on logout', () => {
persistAccessToken('oidc-token', 'session');
persistAccessToken('');
expect(readStoredAccessToken()).toBe('');
});
});
+13 -5
View File
@@ -1,21 +1,29 @@
const AUTH_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_access_token';
const OIDC_SESSION_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_oidc_access_token';
export function readStoredAccessToken() {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? '';
return window.sessionStorage.getItem(OIDC_SESSION_TOKEN_STORAGE_KEY)
?? window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
?? '';
} catch {
return '';
}
}
export function persistAccessToken(value: string) {
export function persistAccessToken(value: string, storage: 'local' | 'session' = 'local') {
if (typeof window === 'undefined') return;
try {
if (value) {
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, value);
} else {
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);
}
} catch {
// Ignore storage failures so private browsing or quota issues do not break login.
+36 -3
View File
@@ -4,6 +4,7 @@ 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';
let activeCompletion: Promise<{ accessToken: string; returnTo: string } | null> | null = null;
interface Discovery {
issuer: string;
@@ -45,13 +46,22 @@ export async function startOIDCLogin() {
window.location.assign(`${discovery.authorization_endpoint}?${params}`);
}
export async function completeOIDCLogin(): Promise<{ accessToken: string; returnTo: string } | null> {
export function completeOIDCLogin(): Promise<{ accessToken: string; returnTo: string } | null> {
if (activeCompletion) return activeCompletion;
activeCompletion = completeOIDCLoginOnce().finally(() => {
activeCompletion = null;
});
return activeCompletion;
}
async function completeOIDCLoginOnce(): 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('统一认证回调不完整');
if (params.get('error')) throw new Error(`统一认证登录被拒绝:${params.get('error')}`);
if (!code || !state) throw new Error('统一认证回调不完整');
const transaction = readTransaction();
window.sessionStorage.removeItem(transactionKey);
if (!transaction || transaction.state !== state || Date.now() - transaction.createdAt > 10 * 60_000) {
@@ -69,7 +79,9 @@ export async function completeOIDCLogin(): Promise<{ accessToken: string; return
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);
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 || '/' };
}
@@ -78,6 +90,7 @@ export async function startOIDCLogout() {
if (!oidcLoginEnabled()) return false;
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;
@@ -112,6 +125,21 @@ function readTransaction(): Transaction | null {
}
}
function validateIDToken(raw: string, expectedNonce: string) {
const parts = raw.split('.');
if (parts.length !== 3) throw new Error('统一认证 ID Token 格式无效');
let claims: { iss?: string; aud?: string | string[]; nonce?: string; exp?: number };
try {
claims = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]))) as typeof claims;
} catch {
throw new Error('统一认证 ID Token 无法解析');
}
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (claims.iss !== issuer || !audiences.includes(clientId) || claims.nonce !== expectedNonce || !claims.exp || claims.exp * 1000 <= Date.now()) {
throw new Error('统一认证 ID Token 声明校验失败');
}
}
function randomValue(bytes: number) {
const value = new Uint8Array(bytes);
crypto.getRandomValues(value);
@@ -122,3 +150,8 @@ function base64url(value: ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(value)))
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
function base64urlDecode(value: string) {
const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - value.length % 4) % 4);
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}
+1 -1
View File
@@ -83,7 +83,7 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
</CardHeader>
<CardContent className="profileGrid">
<InfoItem label="账号" value={owner?.username ?? '-'} />
<InfoItem label="租户" value={owner?.tenantKey ?? 'default'} />
<InfoItem label="租户" value={owner?.tenantKey || owner?.tenantId || 'default'} />
<InfoItem label="身份源" value={owner?.source ?? 'gateway'} />
<InfoItem label="API Key" value={String(props.data.apiKeys.length)} />
</CardContent>