feat(gateway): 接入统一认证中心本地登录
This commit is contained in:
parent
f8d766b916
commit
03abc0eab7
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,6 +4,8 @@ node_modules/
|
||||
.turbo/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.gateway-local-password
|
||||
*.log
|
||||
|
||||
apps/api/bin/
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/web",
|
||||
"command": "pnpm typecheck"
|
||||
"command": "pnpm test && pnpm typecheck"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
apps/web/src/lib/auth-storage.test.ts
Normal file
29
apps/web/src/lib/auth-storage.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user