import type { IncomingMessage, ServerResponse } from 'http'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { clearWorksSquareSession, commitWorksSquareSession, commitWorksSquareSessionFromTokenPayload, discardUnrestorableWorksSquareSession, flushWorksSquareSessionPersistence, getValidWorksSquareAccessToken, getWorksSquareSessionRestoreStatus, getWorksSquareSessionSnapshot, markWorksSquareSessionActive, retryWorksSquareSessionRestore, } from '../../services/works-square-session'; import { clearManagedWorksSquareRuntime, clearManagedWorksSquareRuntimeBestEffort, ensureManagedWorksSquareRuntimeClean, } from '../../services/works-square-runtime'; import { logger } from '../../utils/logger'; import type { WorksSquareTokenPayload } from '../../services/works-square-session'; import { normalizeModuleAccess } from '../../../shared/module-access'; import { getRememberedPasswordState, updateRememberedPassword, } from '../../services/remembered-password'; type PasswordLoginInput = { username?: unknown; password?: unknown; rememberPassword?: unknown; }; type MobileLoginInput = { phone?: unknown; code?: unknown; }; type MobileCodeInput = { phone?: unknown; imageRandomStr?: unknown; imageCode?: unknown; }; type LogoutInput = { accessToken?: unknown; }; type SessionSyncInput = { accessToken?: unknown; refreshToken?: unknown; tokenType?: unknown; expiresAt?: unknown; lastActiveAt?: unknown; }; type SessionRefreshInput = { forceRefresh?: unknown; }; const MAX_AUTH_ERROR_LENGTH = 180; const MAX_CAPTCHA_IMAGE_BYTES = 1024 * 1024; const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Missing ${field}`); } return value.trim(); } function readOptionalTrimmedString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function readOptionalNumber(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function readOptionalBoolean(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } function withoutRefreshToken(payload: unknown): unknown { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload; const { refresh_token: _refreshToken, ...publicPayload } = payload as Record; return publicPayload; } function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string { const apiBase = value.replace(/\/+$/, ''); if (!/^https?:\/\//i.test(apiBase)) { throw new Error('Works Square API base URL must start with http:// or https://'); } return apiBase; } function createWorksUrl(pathname: string): URL { return new URL(`${normalizeWorksBase()}${pathname}`); } async function readResponsePayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; try { return JSON.parse(text) as unknown; } catch { return text; } } function getErrorMessage(payload: unknown, fallback: string): string { const compact = (value: string): string => { const normalized = value.replace(/\s+/g, ' ').trim(); if ( !normalized || normalized.length > MAX_AUTH_ERROR_LENGTH || /; for (const field of ['detail', 'msg', 'message', 'error_description', 'error']) { const value = record[field]; if (typeof value === 'string' && value.trim()) { return compact(value); } } } if (typeof payload === 'string' && payload.trim()) { return compact(payload); } return fallback; } function readExactJsonObject( value: unknown, allowedFields: readonly string[], ): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Request body must be a JSON object'); } const record = value as Record; const unexpectedField = Object.keys(record).find((field) => !allowedFields.includes(field)); if (unexpectedField) { throw new Error(`Unexpected field: ${unexpectedField}`); } return record; } function isFailureEnvelope(payload: unknown): boolean { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false; const record = payload as Record; if (record.success === false || record.data === false) return true; if (typeof record.code === 'number') return record.code !== 0; if (typeof record.code === 'string' && record.code.trim()) return record.code.trim() !== '0'; return false; } async function fetchWorks( pathname: string, init: RequestInit, ): Promise<{ response: Response; payload: unknown } | null> { try { const response = await proxyAwareFetch(createWorksUrl(pathname).toString(), init); return { response, payload: await readResponsePayload(response) }; } catch { return null; } } async function readBoundedResponseBytes(response: Response, maxBytes: number): Promise { if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let totalBytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.byteLength; if (totalBytes > maxBytes) { await reader.cancel(); return null; } chunks.push(value); } } finally { reader.releaseLock(); } return Buffer.concat(chunks, totalBytes); } function sendUpstreamFailure( res: ServerResponse, response: Response | null, payload: unknown, serviceFailureMessage: string, ): void { const isClientError = response !== null && ( response.ok || (response.status >= 400 && response.status < 500) ); sendJson(res, response?.ok ? 400 : (isClientError ? (response?.status ?? 400) : 502), { success: false, error: isClientError ? getErrorMessage(payload, '请求未通过,请检查后重试。') : serviceFailureMessage, }); } async function commitLoginPayload( res: ServerResponse, payload: unknown, onCommitted?: () => Promise, ): Promise { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { sendJson(res, 502, { success: false, error: '登录服务返回了无效响应,请稍后重试。' }); return; } try { const session = await commitWorksSquareSessionFromTokenPayload( payload as WorksSquareTokenPayload, ); await onCommitted?.(); sendJson(res, 200, { success: true, token: withoutRefreshToken(payload), session, }); } catch { sendJson(res, 503, { success: false, error: '登录状态暂时无法保存,请稍后重试。' }); } } async function handlePasswordLogin( req: IncomingMessage, res: ServerResponse, ctx: HostApiContext, ): Promise { if (!await ensureRuntimeReadyForLogin(res, ctx)) return; const body = readExactJsonObject( await parseJsonBody(req), ['username', 'password', 'rememberPassword'], ); const username = readRequiredString(body.username, 'username'); const password = readRequiredString(body.password, 'password'); const rememberPassword = readOptionalBoolean(body.rememberPassword, false); const result = await fetchWorks('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (!result || !result.response.ok || isFailureEnvelope(result.payload)) { sendUpstreamFailure( res, result?.response ?? null, result?.payload, '登录服务暂时不可用,请稍后重试。', ); return; } await commitLoginPayload(res, result.payload, async () => { await updateRememberedPassword( rememberPassword ? { username, password } : null, ); }); } async function handleRememberedPassword(res: ServerResponse): Promise { const state = await getRememberedPasswordState(); res.setHeader('Cache-Control', 'no-store'); sendJson(res, 200, { success: true, ...state }); } async function handleMobileLogin( req: IncomingMessage, res: ServerResponse, ctx: HostApiContext, ): Promise { if (!await ensureRuntimeReadyForLogin(res, ctx)) return; const body = readExactJsonObject( await parseJsonBody(req), ['phone', 'code'], ); const phone = readRequiredString(body.phone, 'phone'); const code = readRequiredString(body.code, 'code'); const result = await fetchWorks('/api/auth/mobile-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, code }), }); if (!result || !result.response.ok || isFailureEnvelope(result.payload)) { sendUpstreamFailure( res, result?.response ?? null, result?.payload, '登录服务暂时不可用,请稍后重试。', ); return; } await commitLoginPayload(res, result.payload); } async function handleMobileCode(req: IncomingMessage, res: ServerResponse): Promise { const body = readExactJsonObject( await parseJsonBody(req), ['phone', 'imageRandomStr', 'imageCode'], ); const phone = readRequiredString(body.phone, 'phone'); const imageRandomStr = readRequiredString(body.imageRandomStr, 'imageRandomStr'); const imageCode = readRequiredString(body.imageCode, 'imageCode'); if (!UUID_PATTERN.test(imageRandomStr)) { throw new Error('imageRandomStr must be a UUID'); } const result = await fetchWorks('/api/auth/mobile-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, imageRandomStr, imageCode }), }); if ( !result || !result.response.ok || !result.payload || typeof result.payload !== 'object' || Array.isArray(result.payload) || isFailureEnvelope(result.payload) ) { sendUpstreamFailure( res, result?.response ?? null, result?.payload, '短信验证码服务暂时不可用,请稍后重试。', ); return; } sendJson(res, 200, { success: true }); } async function handleMobileImageCode(url: URL, res: ServerResponse): Promise { res.setHeader('Cache-Control', 'no-store'); const randomStr = readRequiredString(url.searchParams.get('randomStr'), 'randomStr'); if (!UUID_PATTERN.test(randomStr)) { throw new Error('randomStr must be a UUID'); } if ( url.searchParams.getAll('randomStr').length !== 1 || [...url.searchParams.keys()].some((key) => key !== 'randomStr') ) { throw new Error('Unexpected query parameter'); } let response: Response; try { const upstreamUrl = createWorksUrl('/api/auth/mobile-image-code'); upstreamUrl.searchParams.set('randomStr', randomStr); response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' }); } catch { sendJson(res, 502, { success: false, error: '图形验证码服务暂时不可用,请稍后重试。', }); return; } if (!response.ok) { const payload = await readResponsePayload(response); sendUpstreamFailure(res, response, payload, '图形验证码服务暂时不可用,请稍后重试。'); return; } const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); const contentLength = Number(response.headers.get('content-length')); if ( contentType !== 'image/png' || (Number.isFinite(contentLength) && contentLength > MAX_CAPTCHA_IMAGE_BYTES) ) { sendJson(res, 502, { success: false, error: '图形验证码服务返回了无效图片,请稍后重试。', }); return; } const bytes = await readBoundedResponseBytes(response, MAX_CAPTCHA_IMAGE_BYTES); if ( !bytes || bytes.length < PNG_MAGIC.length || !bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC) ) { sendJson(res, 502, { success: false, error: '图形验证码服务返回了无效图片,请稍后重试。', }); return; } sendJson(res, 200, { success: true, image: { mimeType: 'image/png', dataBase64: bytes.toString('base64'), }, }); } function projectPublicUrl(value: unknown): string | null { if (typeof value !== 'string' || !value.trim()) return null; try { const worksBase = new URL(`${normalizeWorksBase()}/`); const projected = new URL(value.trim(), worksBase); if (projected.username || projected.password) return null; if (projected.origin !== worksBase.origin && projected.protocol !== 'https:') return null; if (projected.origin === worksBase.origin && !['http:', 'https:'].includes(projected.protocol)) { return null; } return projected.toString(); } catch { return null; } } async function handlePublicConfig(res: ServerResponse): Promise { const result = await fetchWorks('/api/app-config', { method: 'GET' }); if (!result || !result.response.ok) { sendUpstreamFailure( res, result?.response ?? null, result?.payload, '登录配置服务暂时不可用,请稍后重试。', ); return; } const config = result.payload && typeof result.payload === 'object' && !Array.isArray(result.payload) ? result.payload as Record : {}; const legal = config.legal && typeof config.legal === 'object' && !Array.isArray(config.legal) ? config.legal as Record : {}; const auth = config.auth && typeof config.auth === 'object' && !Array.isArray(config.auth) ? config.auth as Record : {}; res.setHeader('Cache-Control', 'no-store'); sendJson(res, 200, { success: true, links: { termsUrl: projectPublicUrl(legal.terms_url), privacyUrl: projectPublicUrl(legal.privacy_url), forgotPasswordUrl: projectPublicUrl(auth.forgot_password_url), }, }); } async function handleSessionSync( req: IncomingMessage, res: ServerResponse, ctx: HostApiContext, ): Promise { if (!await ensureWorksSquareSessionRestored()) { sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' }); return; } try { await flushWorksSquareSessionPersistence(); } catch (error) { logger.warn('[auth] Session persistence is temporarily unavailable during sync', error); sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' }); return; } const body = await parseJsonBody(req); let session = getWorksSquareSessionSnapshot(); const legacyAccessToken = readOptionalTrimmedString(body.accessToken); if (!session && legacyAccessToken) { try { await ensureManagedWorksSquareRuntimeClean(ctx); session = await commitWorksSquareSession({ accessToken: legacyAccessToken, refreshToken: readOptionalTrimmedString(body.refreshToken), tokenType: readOptionalTrimmedString(body.tokenType), expiresAt: readOptionalNumber(body.expiresAt), lastActiveAt: readOptionalNumber(body.lastActiveAt), }); } catch (error) { logger.warn('[auth] Failed to persist a migrated Renderer session', error); sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' }); return; } } if (!session) { sendJson(res, 200, { success: true, session: null }); return; } sendJson(res, 200, { success: true, session }); } async function handleSessionRefresh( req: IncomingMessage, res: ServerResponse, ctx: HostApiContext, ): Promise { if (!await ensureWorksSquareSessionRestored()) { sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' }); return; } const body = await parseJsonBody(req); try { const accessToken = await getValidWorksSquareAccessToken({ forceRefresh: readOptionalBoolean(body.forceRefresh, true), }); const session = getWorksSquareSessionSnapshot(); if (accessToken && session) { sendJson(res, 200, { success: true, session }); return; } if (!session) { await clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal session refresh'); sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' }); return; } sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' }); } catch (error) { logger.warn('[auth] Failed to refresh the managed Works Square session', error); sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' }); } } async function handleSessionActivity( res: ServerResponse, ctx: HostApiContext, ): Promise { if (!await ensureWorksSquareSessionRestored()) { sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' }); return; } let session; try { session = await markWorksSquareSessionActive(); } catch (error) { logger.warn('[auth] Failed to persist Works Square session activity', error); sendJson(res, 503, { success: false, error: '登录活动暂时无法保存,请稍后重试。' }); return; } if (!session) { await clearManagedWorksSquareRuntimeBestEffort(ctx, 'idle session activity'); sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' }); return; } sendJson(res, 200, { success: true, session, }); } async function handleCurrentUser(res: ServerResponse, ctx: HostApiContext): Promise { const accessToken = await getValidWorksSquareAccessToken({ forceRefresh: false }); if (!accessToken) { sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' }); return; } const response = await proxyAwareFetch(createWorksUrl('/api/auth/me').toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, }); const payload = await readResponsePayload(response); if (!response.ok) { if (response.status === 401) { clearWorksSquareSession(); await Promise.allSettled([ flushWorksSquareSessionPersistence(), clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal current-user lookup'), ]); } sendJson(res, response.status === 401 ? 401 : 502, { success: false, error: response.status === 401 ? '登录已过期,请重新授权。' : '暂时无法读取模块权限,请稍后重试。', }); return; } const profile = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload as Record : {}; sendJson(res, 200, { success: true, moduleAccess: normalizeModuleAccess(profile.module_access), }); } async function ensureWorksSquareSessionRestored(): Promise { if (getWorksSquareSessionRestoreStatus() === 'unavailable') { await retryWorksSquareSessionRestore(); } return getWorksSquareSessionRestoreStatus() === 'ready'; } async function ensureRuntimeReadyForLogin( res: ServerResponse, ctx: HostApiContext, ): Promise { let discardedUnrestorableSession = false; if (!await ensureWorksSquareSessionRestored()) { if (!await discardUnrestorableWorksSquareSession()) { sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。', }); return false; } discardedUnrestorableSession = true; } try { if (discardedUnrestorableSession) { await clearManagedWorksSquareRuntime(ctx, undefined, true); } await ensureManagedWorksSquareRuntimeClean(ctx); return true; } catch (error) { logger.error('[auth] Failed to clean the previous runtime before login', error); sendJson(res, 503, { success: false, error: '上次登录的本地运行环境尚未清理完成,请稍后重试。', }); return false; } } async function handleSessionClear(res: ServerResponse, ctx: HostApiContext): Promise { const accessToken = getWorksSquareSessionSnapshot()?.accessToken; clearWorksSquareSession(); await Promise.all([ flushWorksSquareSessionPersistence(), clearManagedWorksSquareRuntime(ctx, accessToken), ]); sendJson(res, 200, { success: true }); } async function handleLogout( req: IncomingMessage, res: ServerResponse, ctx: HostApiContext, ): Promise { const body = readExactJsonObject( await parseJsonBody(req), ['accessToken'], ); const rendererAccessToken = readOptionalTrimmedString(body.accessToken); const accessToken = getWorksSquareSessionSnapshot()?.accessToken ?? readRequiredString(rendererAccessToken, 'accessToken'); let cleanupError: Error | null = null; clearWorksSquareSession(); try { await clearManagedWorksSquareRuntime(ctx, accessToken); } catch (error) { cleanupError = error instanceof Error ? error : new Error(String(error)); logger.error('[auth] Failed to clear managed Works Square runtime state during logout', cleanupError); } try { await flushWorksSquareSessionPersistence(); } catch (error) { cleanupError = error instanceof Error ? error : new Error(String(error)); logger.error('[auth] Failed to persist the local session clear during logout', cleanupError); } let response: Response; try { response = await proxyAwareFetch(createWorksUrl('/api/auth/logout').toString(), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, }, }); } catch (error) { if (cleanupError) { sendJson(res, 500, { success: false, error: 'Failed to clear local AI runtime state', }); return; } throw error; } if (cleanupError) { sendJson(res, 500, { success: false, error: 'Failed to clear local AI runtime state', }); return; } if (!response.ok) { const payload = await readResponsePayload(response); sendJson(res, response.status === 401 ? 401 : 502, { success: false, error: getErrorMessage(payload, `Logout failed (${response.status})`), }); return; } sendJson(res, 200, { success: true }); } export async function handleAuthRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (!url.pathname.startsWith('/api/auth')) { return false; } try { if (url.pathname === '/api/auth/login' && req.method === 'POST') { await handlePasswordLogin(req, res, ctx); return true; } if (url.pathname === '/api/auth/remembered-password' && req.method === 'GET') { await handleRememberedPassword(res); return true; } if (url.pathname === '/api/auth/mobile-login' && req.method === 'POST') { await handleMobileLogin(req, res, ctx); return true; } if (url.pathname === '/api/auth/mobile-code' && req.method === 'POST') { await handleMobileCode(req, res); return true; } if (url.pathname === '/api/auth/mobile-image-code' && req.method === 'GET') { await handleMobileImageCode(url, res); return true; } if (url.pathname === '/api/auth/public-config' && req.method === 'GET') { await handlePublicConfig(res); return true; } if (url.pathname === '/api/auth/session/sync' && req.method === 'POST') { await handleSessionSync(req, res, ctx); return true; } if (url.pathname === '/api/auth/session/refresh' && req.method === 'POST') { await handleSessionRefresh(req, res, ctx); return true; } if (url.pathname === '/api/auth/session/activity' && req.method === 'POST') { await handleSessionActivity(res, ctx); return true; } if (url.pathname === '/api/auth/me' && req.method === 'GET') { await handleCurrentUser(res, ctx); return true; } if (url.pathname === '/api/auth/session/clear' && req.method === 'POST') { await handleSessionClear(res, ctx); return true; } if (url.pathname === '/api/auth/logout' && req.method === 'POST') { await handleLogout(req, res, ctx); return true; } sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` }); return true; } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error), }); return true; } }