diff --git a/.env.example b/.env.example index f124b14..1905d2a 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,19 @@ OIDC_GATEWAY_TENANT_KEY= OIDC_BROWSER_SESSION_ENABLED=true # Staging/production must use true. Local HTTP development may use false. OIDC_SESSION_COOKIE_SECURE=false +# Public Client ID generated by Auth Center Control Plane. No Client Secret is used. OIDC_CLIENT_ID= -OIDC_REDIRECT_URI=http://localhost:5178/auth/callback +OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ +# Generate a dedicated value with: openssl rand -base64 32 +# Keep the real value only in a Git-ignored local file or Secret Manager. +OIDC_SESSION_ENCRYPTION_KEY= +OIDC_SESSION_IDLE_TTL_SECONDS=1800 +OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800 +OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 VITE_OIDC_BROWSER_SESSION_ENABLED=true +VITE_OIDC_ENABLED=false +AI_GATEWAY_WEB_BASE_URL=http://localhost:5178 AI_GATEWAY_WEB_BASE_PATH=/ AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine AI_GATEWAY_API_RUNTIME_IMAGE=alpine:3.22 diff --git a/Dockerfile b/Dockerfile index 303e9fd..37f1e91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,16 +72,10 @@ COPY apps/web apps/web ARG VITE_GATEWAY_API_BASE_URL=/gateway-api ARG VITE_OIDC_ENABLED=false -ARG VITE_OIDC_ISSUER= -ARG VITE_OIDC_CLIENT_ID= -ARG VITE_OIDC_REDIRECT_URI= ARG VITE_OIDC_BROWSER_SESSION_ENABLED=true ARG VITE_BASE_PATH=/ ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \ - VITE_OIDC_ISSUER=$VITE_OIDC_ISSUER \ - VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \ - VITE_OIDC_REDIRECT_URI=$VITE_OIDC_REDIRECT_URI \ VITE_OIDC_BROWSER_SESSION_ENABLED=$VITE_OIDC_BROWSER_SESSION_ENABLED \ VITE_BASE_PATH=$VITE_BASE_PATH RUN pnpm --filter @easyai-ai-gateway/web build diff --git a/README.md b/README.md index bd402a3..5f98881 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,15 @@ OIDC_JIT_PROVISIONING_ENABLED=true OIDC_GATEWAY_TENANT_KEY=default OIDC_BROWSER_SESSION_ENABLED=true OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true +OIDC_CLIENT_ID=<公共 Client ID> +OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ +OIDC_SESSION_ENCRYPTION_KEY= ``` `OIDC_GATEWAY_TENANT_KEY` 必须指向已有且启用的 Gateway 租户及其默认用户组;JIT 不会从 Token 自动创建租户。开关默认关闭,关闭后不再创建新用户,但仍可解析此前创建的 `source=oidc` 映射。 -Web Console 默认把已验证的 Auth Center Access Token 转入 `HttpOnly + SameSite=Strict` Cookie,随后清除浏览器 `localStorage/sessionStorage` 中的 OIDC Access Token。Cookie 在同域标签页之间共享且有效期不超过原 Access Token;Gateway 不会二次签发 JWT。Staging、生产及其他非本地环境必须启用 Secure Cookie,并配置明确的 `CORS_ALLOWED_ORIGIN`,禁止 `*`。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。 +Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 Access/Refresh/ID Token,浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新;Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret,也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。 `pnpm dev` 会先创建数据库并执行 migration,然后并行启动: diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e0364fa..d139510 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -50,7 +50,6 @@ import { deleteApiKey, deleteFileStorageChannel, deleteGatewayUser, - deleteOIDCBrowserSession, deletePlatform, deleteTenant, deleteUserGroup, @@ -114,13 +113,11 @@ import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations'; import { persistAccessToken, - persistLegacyOIDCAccessToken, - readLegacyOIDCAccessToken, readStoredAccessToken, } from './lib/auth-storage'; -import { activateOIDCBrowserSession, restoreOIDCBrowserSession } from './lib/oidc-browser-session'; +import { restoreOIDCBrowserSession } from './lib/oidc-browser-session'; import { - completeOIDCLogin, + consumeOIDCCallbackError, oidcBrowserSessionEnabled, oidcLoginEnabled, startOIDCLogin, @@ -254,6 +251,7 @@ export function App() { const [coreMessage, setCoreMessage] = useState(''); const [state, setState] = useState('idle'); const [error, setError] = useState(''); + const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); const loadedDataKeysRef = useRef(new Set()); const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); @@ -288,35 +286,16 @@ export function App() { useEffect(() => { let cancelled = false; void (async () => { - const result = await completeOIDCLogin(); - if (cancelled) return; - if (result) { - if (!oidcBrowserSessionEnabled()) { - persistLegacyOIDCAccessToken(result.accessToken); - setToken(result.accessToken); - applyRoute(parseAppRoute(result.returnTo)); - return; - } - const credential = await activateOIDCBrowserSession(result.accessToken); - if (cancelled) return; - setToken(credential); - applyRoute(parseAppRoute(result.returnTo)); - return; - } - + if (oidcCallbackError) return; if (!oidcBrowserSessionEnabled()) return; - const legacyOIDCToken = readLegacyOIDCAccessToken(); - if (legacyOIDCToken) { - const credential = await activateOIDCBrowserSession(legacyOIDCToken); - if (!cancelled) setToken(credential); - return; - } if (readStoredAccessToken() || !oidcLoginEnabled()) return; const restored = await restoreOIDCBrowserSession(); if (!restored || cancelled) return; setCurrentUser(restored.user); loadedDataKeysRef.current.add('currentUser'); setToken(restored.credential); + setState('idle'); + setError(''); })().catch((err) => { if (cancelled) return; setState('error'); @@ -325,7 +304,10 @@ export function App() { return () => { cancelled = true; }; - }, []); + }, [oidcCallbackError]); + useEffect(() => { + if (token) setOIDCCallbackError(''); + }, [token]); useEffect(() => { void ensureData(['health']); }, []); @@ -1150,33 +1132,27 @@ export function App() { async function signOut() { const wasOIDCSession = token === OIDC_BROWSER_SESSION_CREDENTIAL; - if (wasOIDCSession) { - try { - await deleteOIDCBrowserSession(); - } catch (err) { - setState('error'); - setError(err instanceof Error ? err.message : '统一认证会话注销失败,请重试'); - return; - } - } const shouldEndOIDCSession = wasOIDCSession || currentUser?.source === 'oidc'; - resetAuthenticatedSession(); if (shouldEndOIDCSession) { try { - if (await startOIDCLogout()) return; + if (await startOIDCLogout()) { + resetAuthenticatedSession(); + return; + } } catch { - navigatePath('/'); setState('error'); - setError('Gateway 会话已注销,但统一认证退出失败'); + setError('统一认证退出失败,请重试'); return; } } + resetAuthenticatedSession(); navigatePath('/'); } function loginWithOIDC() { setState('loading'); setError(''); + setOIDCCallbackError(''); void startOIDCLogin().catch((err) => { setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); @@ -1255,7 +1231,7 @@ export function App() { onRefresh={() => void refresh()} onSignOut={signOut} > - {error &&
{error}
} + {(oidcCallbackError || error) &&
{oidcCallbackError || error}
} {activePage === 'home' && } {activePage === 'playground' && ( { ['GATEWAY_USER_PROVISIONING_FAILED', 'Gateway 账号初始化失败,请稍后重试'], ['OIDC_BROWSER_SESSION_DISABLED', 'Gateway 浏览器会话尚未启用'], ['OIDC_SESSION_INVALID', '统一认证会话无效,请重新登录'], - ['OIDC_SESSION_TOKEN_TOO_LARGE', '统一认证凭证过大,无法建立浏览器会话'], + ['OIDC_SESSION_EXPIRED', '统一认证会话已过期,请重新登录'], + ['OIDC_SESSION_REFRESH_UNAVAILABLE', '认证中心暂时不可用,请稍后重试'], + ['OIDC_SESSION_STORE_UNAVAILABLE', '登录会话存储暂时不可用,请稍后重试'], ['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'], ] as const; @@ -38,19 +39,6 @@ describe('OIDC browser session transport', () => { vi.unstubAllGlobals(); }); - it('exchanges the in-memory access token for a credentialed HttpOnly session', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); - vi.stubGlobal('fetch', fetchMock); - - await createOIDCBrowserSession('auth-center-access-token'); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(init.credentials).toBe('include'); - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer auth-center-access-token'); - expect(init.body).toBeUndefined(); - }); - it('uses the shared cookie without sending a synthetic bearer token', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ sub: 'oidc-user' }), { status: 200, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index b062eca..a548d99 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -112,13 +112,6 @@ export async function loginLocalAccount(input: { account: string; password: stri }); } -export async function createOIDCBrowserSession(accessToken: string): Promise { - await request('/api/v1/auth/oidc/session', { - method: 'POST', - token: accessToken, - }); -} - export async function deleteOIDCBrowserSession(): Promise { await request('/api/v1/auth/oidc/session', { auth: false, @@ -1161,7 +1154,9 @@ const gatewayProvisioningErrorMessages: Record = { GATEWAY_USER_PROVISIONING_FAILED: 'Gateway 账号初始化失败,请稍后重试', OIDC_BROWSER_SESSION_DISABLED: 'Gateway 浏览器会话尚未启用', OIDC_SESSION_INVALID: '统一认证会话无效,请重新登录', - OIDC_SESSION_TOKEN_TOO_LARGE: '统一认证凭证过大,无法建立浏览器会话', + OIDC_SESSION_EXPIRED: '统一认证会话已过期,请重新登录', + OIDC_SESSION_REFRESH_UNAVAILABLE: '认证中心暂时不可用,请稍后重试', + OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试', OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试', }; diff --git a/apps/web/src/lib/auth-storage.test.ts b/apps/web/src/lib/auth-storage.test.ts index 40284d3..faefffa 100644 --- a/apps/web/src/lib/auth-storage.test.ts +++ b/apps/web/src/lib/auth-storage.test.ts @@ -20,9 +20,10 @@ describe('auth storage', () => { expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull(); }); - it('reads a legacy OIDC session token only for one-time cookie migration', () => { + it('removes and never reads a legacy OIDC browser token', () => { window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'legacy-oidc-token'); - expect(readStoredAccessToken()).toBe('legacy-oidc-token'); + expect(readStoredAccessToken()).toBe(''); + expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull(); }); it('clears both token stores on logout', () => { diff --git a/apps/web/src/lib/auth-storage.ts b/apps/web/src/lib/auth-storage.ts index 2b4da06..2197d3b 100644 --- a/apps/web/src/lib/auth-storage.ts +++ b/apps/web/src/lib/auth-storage.ts @@ -4,34 +4,14 @@ const OIDC_SESSION_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_oidc_access_token'; export function readStoredAccessToken() { if (typeof window === 'undefined') return ''; try { - return window.sessionStorage.getItem(OIDC_SESSION_TOKEN_STORAGE_KEY) - ?? window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) - ?? ''; + // Remove credentials written by the retired browser-token implementation. + window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY); + return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? ''; } catch { return ''; } } -export function readLegacyOIDCAccessToken() { - if (typeof window === 'undefined') return ''; - try { - return window.sessionStorage.getItem(OIDC_SESSION_TOKEN_STORAGE_KEY) ?? ''; - } catch { - return ''; - } -} - -export function persistLegacyOIDCAccessToken(value: string) { - if (typeof window === 'undefined') return; - try { - window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); - if (value) window.sessionStorage.setItem(OIDC_SESSION_TOKEN_STORAGE_KEY, value); - else window.sessionStorage.removeItem(OIDC_SESSION_TOKEN_STORAGE_KEY); - } catch { - // Compatibility-only rollback path for deployments with browser sessions disabled. - } -} - export function persistAccessToken(value: string) { if (typeof window === 'undefined') return; try { diff --git a/apps/web/src/lib/oidc-browser-session.test.ts b/apps/web/src/lib/oidc-browser-session.test.ts index 7c53b97..c522bd4 100644 --- a/apps/web/src/lib/oidc-browser-session.test.ts +++ b/apps/web/src/lib/oidc-browser-session.test.ts @@ -1,31 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { OIDC_BROWSER_SESSION_CREDENTIAL } from '../api'; -import { activateOIDCBrowserSession, restoreOIDCBrowserSession } from './oidc-browser-session'; - -class MemoryStorage { - private values = new Map(); - 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); } -} +import { restoreOIDCBrowserSession } from './oidc-browser-session'; describe('OIDC browser session lifecycle', () => { - beforeEach(() => { - vi.stubGlobal('window', { localStorage: new MemoryStorage(), sessionStorage: new MemoryStorage() }); - }); - - it('moves an OIDC access token into the HttpOnly session and clears browser token storage', async () => { - window.localStorage.setItem('easyai_ai_gateway_access_token', 'old-local-token'); - window.sessionStorage.setItem('easyai_ai_gateway_oidc_access_token', 'old-oidc-token'); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); - - const credential = await activateOIDCBrowserSession('new-oidc-token'); - - expect(credential).toBe(OIDC_BROWSER_SESSION_CREDENTIAL); - expect(window.localStorage.getItem('easyai_ai_gateway_access_token')).toBeNull(); - expect(window.sessionStorage.getItem('easyai_ai_gateway_oidc_access_token')).toBeNull(); - }); - it('restores a shared cookie session in a fresh tab', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ sub: 'platform-user', source: 'oidc', gatewayUserId: 'gateway-user', diff --git a/apps/web/src/lib/oidc-browser-session.ts b/apps/web/src/lib/oidc-browser-session.ts index baec9bc..9b04b1f 100644 --- a/apps/web/src/lib/oidc-browser-session.ts +++ b/apps/web/src/lib/oidc-browser-session.ts @@ -1,10 +1,8 @@ import { - createOIDCBrowserSession, GatewayApiError, getCurrentUser, OIDC_BROWSER_SESSION_CREDENTIAL, } from '../api'; -import { persistAccessToken } from './auth-storage'; type CurrentUser = Awaited>; @@ -13,13 +11,6 @@ export interface RestoredOIDCBrowserSession { user: CurrentUser; } -export async function activateOIDCBrowserSession(accessToken: string) { - if (!accessToken.trim()) throw new Error('统一认证未返回 Access Token'); - await createOIDCBrowserSession(accessToken); - persistAccessToken(''); - return OIDC_BROWSER_SESSION_CREDENTIAL; -} - export async function restoreOIDCBrowserSession(): Promise { try { const user = await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL); diff --git a/apps/web/src/lib/oidc.test.ts b/apps/web/src/lib/oidc.test.ts new file mode 100644 index 0000000..2f4aaf6 --- /dev/null +++ b/apps/web/src/lib/oidc.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('OIDC BFF navigation', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('sends only returnTo to the Gateway login endpoint', async () => { + vi.stubEnv('VITE_OIDC_ENABLED', 'true'); + vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); + const assign = vi.fn(); + vi.stubGlobal('window', { + location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign }, + }); + const { startOIDCLogin } = await import('./oidc'); + await startOIDCLogin(); + const target = new URL(String(assign.mock.calls[0]?.[0])); + expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login'); + expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance'); + expect(target.searchParams.has('client_id')).toBe(false); + expect(target.searchParams.has('code_challenge')).toBe(false); + }); + + it('submits logout as a top-level POST without exposing tokens', async () => { + vi.stubEnv('VITE_OIDC_ENABLED', 'true'); + vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); + const submit = vi.fn(); + const form = { method: '', action: '', style: { display: '' }, submit }; + const appendChild = vi.fn(); + vi.stubGlobal('document', { createElement: vi.fn(() => form), body: { appendChild } }); + const { startOIDCLogout } = await import('./oidc'); + await expect(startOIDCLogout()).resolves.toBe(true); + expect(form.method).toBe('POST'); + expect(form.action).toBe('https://gateway.example.com/gateway-api/api/v1/auth/oidc/logout'); + expect(Object.keys(form)).not.toContain('accessToken'); + expect(submit).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/lib/oidc.ts b/apps/web/src/lib/oidc.ts index c1c2263..b703678 100644 --- a/apps/web/src/lib/oidc.ts +++ b/apps/web/src/lib/oidc.ts @@ -1,29 +1,18 @@ const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true'; const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false'; -const issuer = (import.meta.env.VITE_OIDC_ISSUER ?? '').replace(/\/$/, ''); -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; +const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, ''); -interface Discovery { - issuer: string; - authorization_endpoint: string; - token_endpoint: string; - end_session_endpoint?: string; -} - -interface Transaction { - state: string; - nonce: string; - verifier: string; - returnTo: string; - createdAt: number; -} +const callbackErrors: Record = { + OIDC_LOGIN_INVALID: '统一认证登录事务无效或已过期,请重新登录', + OIDC_TOKEN_EXCHANGE_FAILED: '统一认证登录结果无效,请重新登录', + OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试', + GATEWAY_USER_NOT_PROVISIONED: '该账号尚未开通 EasyAI Gateway', + GATEWAY_USER_DISABLED: '该 Gateway 账号已停用,请联系管理员', + GATEWAY_TENANT_UNAVAILABLE: 'Gateway 租户尚未就绪,请联系管理员', +}; export function oidcLoginEnabled() { - return enabled && Boolean(issuer && clientId); + return enabled; } export function oidcBrowserSessionEnabled() { @@ -31,131 +20,30 @@ export function oidcBrowserSessionEnabled() { } export async function startOIDCLogin() { - assertConfigured(); - const discovery = await getDiscovery(); - const verifier = randomValue(48); - const transaction: Transaction = { - state: randomValue(24), - nonce: randomValue(24), - verifier, - returnTo: window.location.pathname, - createdAt: Date.now(), - }; - window.sessionStorage.setItem(transactionKey, JSON.stringify(transaction)); - const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); - const params = new URLSearchParams({ - response_type: 'code', client_id: clientId, redirect_uri: redirectURI(), - scope: 'openid profile', state: transaction.state, nonce: transaction.nonce, - code_challenge: challenge, code_challenge_method: 'S256', - }); - window.location.assign(`${discovery.authorization_endpoint}?${params}`); -} - -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 (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) { - throw new Error('统一认证登录事务已失效'); - } - const discovery = await getDiscovery(); - const response = await fetch(discovery.token_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', client_id: clientId, redirect_uri: redirectURI(), - code, code_verifier: transaction.verifier, - }), - }); - 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) throw new Error('统一认证未返回 ID Token'); - validateIDToken(payload.id_token, transaction.nonce); - window.history.replaceState({}, '', transaction.returnTo || '/'); - return { accessToken: payload.access_token, returnTo: transaction.returnTo || '/' }; + if (!oidcLoginEnabled()) throw new Error('统一认证未配置'); + const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/'; + const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`); + loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/'); + window.location.assign(loginURL.toString()); } export async function startOIDCLogout() { if (!oidcLoginEnabled()) return false; - const idToken = window.sessionStorage.getItem(idTokenKey); - window.sessionStorage.removeItem(idTokenKey); - window.sessionStorage.removeItem(transactionKey); - const discovery = await getDiscovery(); - if (!discovery.end_session_endpoint) return false; - const params = new URLSearchParams({ client_id: clientId, post_logout_redirect_uri: window.location.origin + '/' }); - if (idToken) params.set('id_token_hint', idToken); - window.location.assign(`${discovery.end_session_endpoint}?${params}`); + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`; + form.style.display = 'none'; + document.body.appendChild(form); + form.submit(); return true; } -function assertConfigured() { - if (!oidcLoginEnabled()) throw new Error('统一认证未配置'); -} - -async function getDiscovery(): Promise { - const response = await fetch(`${issuer}/.well-known/openid-configuration`, { headers: { Accept: 'application/json' } }); - if (!response.ok) throw new Error('统一认证 Discovery 不可用'); - const value = await response.json() as Discovery; - if (value.issuer !== issuer || !value.authorization_endpoint || !value.token_endpoint) { - throw new Error('统一认证 Discovery 不符合预期'); - } - return value; -} - -function redirectURI() { - return configuredRedirect || `${window.location.origin}/auth/callback`; -} - -function readTransaction(): Transaction | null { - try { - return JSON.parse(window.sessionStorage.getItem(transactionKey) ?? 'null') as Transaction | null; - } catch { - return 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); - return base64url(value.buffer); -} - -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)); +export function consumeOIDCCallbackError() { + const params = new URLSearchParams(window.location.search); + const code = params.get('oidcError') ?? ''; + if (!code) return ''; + params.delete('oidcError'); + const query = params.toString(); + window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`); + return callbackErrors[code] ?? '统一认证登录失败,请重试'; } diff --git a/docker-compose.yml b/docker-compose.yml index 39ae541..67df0f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,13 @@ x-api-environment: &api-environment OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-} OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} OIDC_SESSION_COOKIE_SECURE: ${OIDC_SESSION_COOKIE_SECURE:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} + OIDC_POST_LOGOUT_REDIRECT_URI: ${OIDC_POST_LOGOUT_REDIRECT_URI:-} + OIDC_SESSION_ENCRYPTION_KEY: ${OIDC_SESSION_ENCRYPTION_KEY:-} + OIDC_SESSION_IDLE_TTL_SECONDS: ${OIDC_SESSION_IDLE_TTL_SECONDS:-1800} + OIDC_SESSION_ABSOLUTE_TTL_SECONDS: ${OIDC_SESSION_ABSOLUTE_TTL_SECONDS:-28800} + OIDC_SESSION_REFRESH_BEFORE_SECONDS: ${OIDC_SESSION_REFRESH_BEFORE_SECONDS:-60} SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000} SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me} SERVER_MAIN_INTERNAL_KEY: ${SERVER_MAIN_INTERNAL_KEY:-gateway} @@ -28,6 +35,7 @@ x-api-environment: &api-environment TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS: ${TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS:-10} CORS_ALLOWED_ORIGIN: ${AI_GATEWAY_COMPOSE_CORS_ALLOWED_ORIGIN:-http://localhost:5178,http://127.0.0.1:5178} AI_GATEWAY_PUBLIC_BASE_URL: ${AI_GATEWAY_COMPOSE_PUBLIC_BASE_URL:-http://localhost:8088} + AI_GATEWAY_WEB_BASE_URL: ${AI_GATEWAY_COMPOSE_WEB_BASE_URL:-http://localhost:5178} AI_GATEWAY_GENERATED_STORAGE_DIR: /app/data/static/generated AI_GATEWAY_UPLOADED_STORAGE_DIR: /app/data/static/uploaded @@ -112,9 +120,6 @@ services: NODE_BUILD_IMAGE: ${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine} WEB_RUNTIME_IMAGE: ${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine} VITE_OIDC_ENABLED: ${OIDC_ENABLED:-false} - VITE_OIDC_ISSUER: ${OIDC_ISSUER:-} - VITE_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} - VITE_OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} VITE_OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} VITE_BASE_PATH: ${AI_GATEWAY_WEB_BASE_PATH:-/} ports: diff --git a/docs/oidc-jit-provisioning.md b/docs/oidc-jit-provisioning.md index aaaf9aa..bf2dea5 100644 --- a/docs/oidc-jit-provisioning.md +++ b/docs/oidc-jit-provisioning.md @@ -14,6 +14,13 @@ OIDC_JIT_PROVISIONING_ENABLED=true OIDC_GATEWAY_TENANT_KEY=default OIDC_BROWSER_SESSION_ENABLED=true OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true +OIDC_CLIENT_ID= +OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback +OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ +OIDC_SESSION_ENCRYPTION_KEY=<独立的 32 字节随机密钥,base64 编码> +OIDC_SESSION_IDLE_TTL_SECONDS=1800 +OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800 +OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 ``` - `OIDC_JIT_PROVISIONING_ENABLED` 默认 `false`。关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。 @@ -22,14 +29,15 @@ OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true ## Web Console 浏览器会话 -- OIDC 回调仍使用 Authorization Code + PKCE 从 Auth Center 获取 Access Token,但 Token 只在回调函数内存中短暂存在。 -- Web 随即调用 `POST /api/v1/auth/oidc/session`;Gateway 再次完成全部 OIDC 校验后,将原 Auth Center Access Token 写入 `HttpOnly + SameSite=Strict` Cookie,不签发第二枚 Gateway JWT。 -- Cookie 有效期不超过 Access Token 的 `exp`,所有 Cookie 请求仍经过 JWKS、Issuer、Audience、`tid`、Scope、角色及可选 Introspection 校验。 -- 建立成功后清除旧的 OIDC `sessionStorage` 和可能冲突的 `localStorage` Token;新标签页通过 Cookie 调用 `/api/v1/me` 自动恢复同一登录态。 +- Web 只跳转 `GET /api/v1/auth/oidc/login`。Gateway 生成 state、nonce、PKCE verifier,并以 Authorization Code + PKCE S256 完成换码;公共 Client 请求只发送 `client_id`,不使用 Client Secret。 +- Access Token、Refresh Token 和 ID Token 使用 AES-256-GCM 加密后存入 PostgreSQL;数据库只保存随机 Session Cookie 的 SHA-256。浏览器 JavaScript、响应体和 Web Storage 均不接触 Token。 +- 浏览器只保存 32 字节随机、`HttpOnly + SameSite=Strict` 的 Session Cookie。新标签页通过该 Cookie 调用 `/api/v1/me` 恢复登录态;Gateway 不签发第二枚 JWT。 +- Access Token 继续保持 5 分钟。认证请求发现剩余时间不超过 60 秒时使用 Refresh Token 自动刷新;多实例通过 PostgreSQL 刷新租约保证同一版本只刷新一次,并原子保存旋转后的 Refresh Token。 +- Session 闲置 30 分钟、绝对最长 8 小时。闲置 5~30 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。 - 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权的 POST、PUT、PATCH、DELETE 必须携带 `CORS_ALLOWED_ORIGIN` 白名单中的 Origin,否则返回结构化 403。 - Staging、生产及其他非本地环境启动时强制 `OIDC_SESSION_COOKIE_SECURE=true`,并拒绝带 `*` 的凭据型 CORS 配置。本地 HTTP 开发和自动化测试可以显式设为 `false`。 -- `DELETE /api/v1/auth/oidc/session` 使 Cookie 立即过期,然后前端进入 Auth Center OIDC 登出。浏览器会话关闭时不会创建或撤销 Gateway API Key。 -- 回滚时同时设置后端 `OIDC_BROWSER_SESSION_ENABLED=false` 和 Web 构建参数 `VITE_OIDC_BROWSER_SESSION_ENABLED=false`,恢复旧的标签页级 `sessionStorage` 行为。 +- `POST /api/v1/auth/oidc/logout` 校验可信 Origin、删除本地 Session、使用公共 Client ID 撤销 Refresh Token,并跳转 Auth Center 退出地址;`DELETE /api/v1/auth/oidc/session` 保留为幂等的本地删除接口。 +- `OIDC_SESSION_ENCRYPTION_KEY` 必须来自 Git 忽略的本地文件、Kubernetes Secret 或 Secret Manager,不得复用 JWT Secret。关闭 `OIDC_BROWSER_SESSION_ENABLED` 可停止新会话;回滚镜像后已创建记录作为惰性数据保留。 ## 数据和事务语义 @@ -50,8 +58,10 @@ OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true | 503 | `GATEWAY_TENANT_UNAVAILABLE` | 配置租户或用户组不存在/未启用 | | 503 | `GATEWAY_USER_PROVISIONING_FAILED` | 事务或存储故障;响应不暴露内部错误 | | 404 | `OIDC_BROWSER_SESSION_DISABLED` | Gateway 浏览器会话功能未启用 | -| 401 | `OIDC_SESSION_INVALID` | 不是有效的 Auth Center OIDC Access Token 或已过期 | -| 400 | `OIDC_SESSION_TOKEN_TOO_LARGE` | Access Token 超过安全 Cookie 大小限制 | +| 401 | `OIDC_SESSION_INVALID` | Session 不存在、Cookie 格式错误或 Token 身份不匹配 | +| 401 | `OIDC_SESSION_EXPIRED` | 闲置/绝对期限已到或 Refresh Token 已失效 | +| 503 | `OIDC_SESSION_REFRESH_UNAVAILABLE` | Access Token 已过期且认证中心暂时不可用 | +| 503 | `OIDC_SESSION_STORE_UNAVAILABLE` | 数据库或 Token 密文不可用 | | 403 | `OIDC_SESSION_CSRF_REJECTED` | Cookie 写请求缺少可信 Origin | `ErrLocalUserRequired` 仅作为防御性内部错误保留,对外统一转换为结构化 403。 diff --git a/scripts/dev.sh b/scripts/dev.sh index cf14c80..8c2e173 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -22,9 +22,6 @@ load_local_env() { 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:-}}" export VITE_OIDC_BROWSER_SESSION_ENABLED="${VITE_OIDC_BROWSER_SESSION_ENABLED:-${OIDC_BROWSER_SESSION_ENABLED:-true}}" }