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

This commit is contained in:
wangbo 2026-07-12 20:45:35 +08:00
parent f8d766b916
commit 03abc0eab7
10 changed files with 123 additions and 15 deletions

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ node_modules/
.turbo/
.DS_Store
.env
.env.local
.gateway-local-password
*.log
apps/api/bin/

View File

@ -7,7 +7,8 @@
"dev": "vite --host 0.0.0.0 --port 5178",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4178",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@assistant-ui/react": "^0.14.0",
@ -42,6 +43,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"vitest": "3.2.4"
}
}

View File

@ -31,7 +31,7 @@
"executor": "nx:run-commands",
"options": {
"cwd": "apps/web",
"command": "pnpm typecheck"
"command": "pnpm test && pnpm typecheck"
}
}
}

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');

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('');
});
});

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.

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));
}

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>

View File

@ -120,6 +120,9 @@ importers:
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
specifier: 3.2.4
version: 3.2.4(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
packages/contracts:
devDependencies:

View File

@ -3,6 +3,32 @@ set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
load_local_env() {
local env_file
for env_file in "${PROJECT_ROOT}/.env" "${PROJECT_ROOT}/.env.local"; do
[[ -f "$env_file" ]] || continue
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" == \#* || "$line" != *=* ]] && continue
local key="${line%%=*}"
local value="${line#*=}"
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
export "$key=$value"
done < "$env_file"
done
export VITE_OIDC_ENABLED="${VITE_OIDC_ENABLED:-${OIDC_ENABLED:-false}}"
export VITE_OIDC_ISSUER="${VITE_OIDC_ISSUER:-${OIDC_ISSUER:-}}"
export VITE_OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-}}"
export VITE_OIDC_REDIRECT_URI="${VITE_OIDC_REDIRECT_URI:-${OIDC_REDIRECT_URI:-}}"
}
load_local_env
stop_stale_api_processes() {
local api_port="${HTTP_ADDR:-:8088}"
api_port="${api_port##*:}"
@ -64,9 +90,13 @@ if [[ -z "${AI_GATEWAY_PG_PASSWORD:-}" ]] && docker inspect "$AI_GATEWAY_PG_CONT
fi
export AI_GATEWAY_PG_PASSWORD="${AI_GATEWAY_PG_PASSWORD:-easyai2025}"
export AI_GATEWAY_DATABASE_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}"
export AI_GATEWAY_DATABASE_URL="${AI_GATEWAY_DATABASE_URL:-postgresql://${AI_GATEWAY_PG_USER}:${AI_GATEWAY_PG_PASSWORD}@localhost:5432/${AI_GATEWAY_DATABASE_NAME}?sslmode=disable}"
if docker inspect "$AI_GATEWAY_PG_CONTAINER" >/dev/null 2>&1; then
export AI_GATEWAY_DATABASE_URL="postgresql://${AI_GATEWAY_PG_USER}:${AI_GATEWAY_PG_PASSWORD}@localhost:5432/${AI_GATEWAY_DATABASE_NAME}?sslmode=disable"
else
export AI_GATEWAY_DATABASE_URL="${AI_GATEWAY_DATABASE_URL:-postgresql://${AI_GATEWAY_PG_USER}:${AI_GATEWAY_PG_PASSWORD}@localhost:5432/${AI_GATEWAY_DATABASE_NAME}?sslmode=disable}"
fi
echo "[ai-gateway] using database: ${AI_GATEWAY_DATABASE_URL}"
echo "[ai-gateway] using configured local database connection (credentials redacted)"
if [[ "${AI_GATEWAY_SKIP_DB_CREATE:-}" == "1" ]]; then
echo "[ai-gateway] skipping Docker database creation"