feat(gateway): 接入统一认证中心本地登录
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user