feat(gateway): 接入统一认证中心本地登录
This commit is contained in:
@@ -4,6 +4,8 @@ node_modules/
|
|||||||
.turbo/
|
.turbo/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.gateway-local-password
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
apps/api/bin/
|
apps/api/bin/
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite --host 0.0.0.0 --port 5178",
|
"dev": "vite --host 0.0.0.0 --port 5178",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0 --port 4178",
|
"preview": "vite preview --host 0.0.0.0 --port 4178",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/react": "^0.14.0",
|
"@assistant-ui/react": "^0.14.0",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"executor": "nx:run-commands",
|
"executor": "nx:run-commands",
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "apps/web",
|
"cwd": "apps/web",
|
||||||
"command": "pnpm typecheck"
|
"command": "pnpm test && pnpm typecheck"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,8 +269,9 @@ export function App() {
|
|||||||
void completeOIDCLogin()
|
void completeOIDCLogin()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
persistAccessToken(result.accessToken);
|
persistAccessToken(result.accessToken, 'session');
|
||||||
setToken(result.accessToken);
|
setToken(result.accessToken);
|
||||||
|
applyRoute(parseAppRoute(result.returnTo));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setState('error');
|
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 AUTH_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_access_token';
|
||||||
|
const OIDC_SESSION_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_oidc_access_token';
|
||||||
|
|
||||||
export function readStoredAccessToken() {
|
export function readStoredAccessToken() {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function persistAccessToken(value: string) {
|
export function persistAccessToken(value: string, storage: 'local' | 'session' = 'local') {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
if (value) {
|
if (!value) {
|
||||||
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, value);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
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 {
|
} catch {
|
||||||
// Ignore storage failures so private browsing or quota issues do not break login.
|
// 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 configuredRedirect = import.meta.env.VITE_OIDC_REDIRECT_URI ?? '';
|
||||||
const transactionKey = 'easyai.gateway.oidc.transaction';
|
const transactionKey = 'easyai.gateway.oidc.transaction';
|
||||||
const idTokenKey = 'easyai.gateway.oidc.id-token';
|
const idTokenKey = 'easyai.gateway.oidc.id-token';
|
||||||
|
let activeCompletion: Promise<{ accessToken: string; returnTo: string } | null> | null = null;
|
||||||
|
|
||||||
interface Discovery {
|
interface Discovery {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
@@ -45,13 +46,22 @@ export async function startOIDCLogin() {
|
|||||||
window.location.assign(`${discovery.authorization_endpoint}?${params}`);
|
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;
|
if (!oidcLoginEnabled()) return null;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const code = params.get('code');
|
const code = params.get('code');
|
||||||
const state = params.get('state');
|
const state = params.get('state');
|
||||||
if (!code && !state) return null;
|
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();
|
const transaction = readTransaction();
|
||||||
window.sessionStorage.removeItem(transactionKey);
|
window.sessionStorage.removeItem(transactionKey);
|
||||||
if (!transaction || transaction.state !== state || Date.now() - transaction.createdAt > 10 * 60_000) {
|
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 交换失败');
|
if (!response.ok) throw new Error('统一认证 Token 交换失败');
|
||||||
const payload = await response.json() as { access_token?: string; id_token?: string };
|
const payload = await response.json() as { access_token?: string; id_token?: string };
|
||||||
if (!payload.access_token) throw new Error('统一认证未返回 Access Token');
|
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 || '/');
|
window.history.replaceState({}, '', transaction.returnTo || '/');
|
||||||
return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' };
|
return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' };
|
||||||
}
|
}
|
||||||
@@ -78,6 +90,7 @@ export async function startOIDCLogout() {
|
|||||||
if (!oidcLoginEnabled()) return false;
|
if (!oidcLoginEnabled()) return false;
|
||||||
const idToken = window.sessionStorage.getItem(idTokenKey);
|
const idToken = window.sessionStorage.getItem(idTokenKey);
|
||||||
window.sessionStorage.removeItem(idTokenKey);
|
window.sessionStorage.removeItem(idTokenKey);
|
||||||
|
window.sessionStorage.removeItem(transactionKey);
|
||||||
if (!idToken) return false;
|
if (!idToken) return false;
|
||||||
const discovery = await getDiscovery();
|
const discovery = await getDiscovery();
|
||||||
if (!discovery.end_session_endpoint) return false;
|
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) {
|
function randomValue(bytes: number) {
|
||||||
const value = new Uint8Array(bytes);
|
const value = new Uint8Array(bytes);
|
||||||
crypto.getRandomValues(value);
|
crypto.getRandomValues(value);
|
||||||
@@ -122,3 +150,8 @@ function base64url(value: ArrayBuffer) {
|
|||||||
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
||||||
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
.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>
|
</CardHeader>
|
||||||
<CardContent className="profileGrid">
|
<CardContent className="profileGrid">
|
||||||
<InfoItem label="账号" value={owner?.username ?? '-'} />
|
<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="身份源" value={owner?.source ?? 'gateway'} />
|
||||||
<InfoItem label="API Key" value={String(props.data.apiKeys.length)} />
|
<InfoItem label="API Key" value={String(props.data.apiKeys.length)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Generated
+3
@@ -120,6 +120,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
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:
|
packages/contracts:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
|||||||
+32
-2
@@ -3,6 +3,32 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
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() {
|
stop_stale_api_processes() {
|
||||||
local api_port="${HTTP_ADDR:-:8088}"
|
local api_port="${HTTP_ADDR:-:8088}"
|
||||||
api_port="${api_port##*:}"
|
api_port="${api_port##*:}"
|
||||||
@@ -64,9 +90,13 @@ if [[ -z "${AI_GATEWAY_PG_PASSWORD:-}" ]] && docker inspect "$AI_GATEWAY_PG_CONT
|
|||||||
fi
|
fi
|
||||||
export AI_GATEWAY_PG_PASSWORD="${AI_GATEWAY_PG_PASSWORD:-easyai2025}"
|
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_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
|
if [[ "${AI_GATEWAY_SKIP_DB_CREATE:-}" == "1" ]]; then
|
||||||
echo "[ai-gateway] skipping Docker database creation"
|
echo "[ai-gateway] skipping Docker database creation"
|
||||||
|
|||||||
Reference in New Issue
Block a user