From 86ece3a430123f4bc20991f957e3fd09dfb9c598 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 7 Aug 2026 16:11:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=B8=83=E5=A4=A9=E6=BB=91=E5=8A=A8=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:解决短效访问令牌到期后客户端一小时掉登录的问题。 实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。 --- .../20260807-client-login-sliding-7d2a.md | 74 ++ README.md | 1 + electron/api/routes/auth.ts | 288 +++++--- electron/image-workspace/module.ts | 7 +- .../image-workspace/works-square-workspace.ts | 76 ++- electron/main/index.ts | 45 +- electron/preload/index.ts | 1 + electron/services/works-square-runtime.ts | 103 +++ electron/services/works-square-session.ts | 631 ++++++++++++++++-- shared/auth-session.ts | 2 + src/App.tsx | 31 + src/lib/host-events.ts | 1 + src/stores/auth.ts | 499 +++++++++++--- tests/setup.ts | 6 + tests/unit/auth-routes.test.ts | 428 +++++++++++- tests/unit/auth-store.test.ts | 500 ++++++++++++-- tests/unit/login-page.test.tsx | 109 ++- .../works-square-design-workspace.test.ts | 41 +- .../works-square-session-persistence.test.ts | 100 +++ tests/unit/works-square-session.test.ts | 324 ++++++++- 20 files changed, 2941 insertions(+), 326 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260807-client-login-sliding-7d2a.md create mode 100644 electron/services/works-square-runtime.ts create mode 100644 shared/auth-session.ts create mode 100644 tests/unit/works-square-session-persistence.test.ts diff --git a/.project-docs/30-worklog/tasks/20260807-client-login-sliding-7d2a.md b/.project-docs/30-worklog/tasks/20260807-client-login-sliding-7d2a.md new file mode 100644 index 0000000..4b57ab1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260807-client-login-sliding-7d2a.md @@ -0,0 +1,74 @@ +# Task: 客户端登录滑动续期与七天闲置失效 + +## Identity + +- Task ID: 20260807-client-login-sliding-7d2a +- Mode: Feature +- Branch: codex/login-sliding-7d2a +- Worktree: D:\Datas\OthersProjects\makelore-login-sliding-7d2a +- Base commit: 7b23cce67a1b9e61a48d89dedbd088d37b10ecd4 +- Owner: codex +- Status: Done + +## Scope + +- 修复客户端启动时 access token 过期即清空整个会话的问题。 +- 由 Electron Main 统一执行 refresh token 换取、轮换保存与并发单飞。 +- 记录真实用户活跃时间;每天使用可持续续期,连续 7 天未使用则要求重新授权。 +- 后台保活不计入用户活跃,并补齐 Main、Host API、Renderer Store 聚焦测试。 + +## Intent And Constraints + +- access token 保持短效,不把 7 天直接改成长效 Bearer token。 +- 真实前台使用才推进滑动窗口;后台定时刷新不能无限延长登录。 +- 先判断 7 天闲置,再允许刷新,过期后不得被一次迟到的活动复活。 +- 保留 OAuth 返回的旋转 refresh token,并在 Electron Main 中加密、原子持久化;Renderer 不保存新 refresh token。 +- 所有代码与 Git 操作仅限隔离工作区,不触碰原 Makelore 工作区未提交改动。 + +## Plan + +1. 用失败测试固定每日活跃、7 天边界、后台刷新和并发单飞语义。 +2. 扩展 Main 会话模型与 Host API,使刷新和活跃状态由 Main 统一管理。 +3. 调整 Renderer 启动恢复、持久化和前台/定时维护逻辑。 +4. 运行聚焦测试、类型检查、Lint、构建与文档漂移检查。 + +## Outcome + +- 新增 Main-owned 会话服务:使用系统安全存储加密 refresh token,启动恢复、刷新单飞、 + 轮换凭据落盘屏障与恢复失败保护均在 Main 内完成。 +- 真实键盘、鼠标、触摸活动每分钟至多推进一次 `lastActiveAt`,并在 access token 临近过期时 + 触发刷新;后台令牌消费者不推进用户活跃时间。 +- 连续 7 天无真实活动后终止会话,并由 Main 统一清理 AI gateway 凭据、Provider key、 + OpenCode 运行时和绘画事件会话;清理与新登录串行,避免跨账号代际竞态。 +- Renderer 只持久化公开会话;旧版本已存在的 refresh token 仅在 Main 安全落盘成功前保留在 + 原 localStorage,接管成功后立即移除;新 refresh token 从不返回或写入 Renderer。Linux + `basic_text` 等不受保护的存储后端不会落盘 refresh token。 +- 临时密钥库/磁盘故障返回 503 并保留可恢复状态,不再误判为授权失效;过期、退出和并发刷新 + 的终态/跨代边界均有回归测试。 +- Electron Store 外层 JSON 损坏会安全重建;密文暂时不可读时先保留,用户显式重新授权可清除 + 不可恢复记录后继续登录,不会因坏记录永久卡在启动或 503。 +- 登出及终态清理会先撤销本地派生凭据,并使用清理前捕获的 Main access token 尝试关闭远端绘画 + 事件会话;无论远端结果都轮换本地幂等键,显式登出仍会对远端关闭失败返回错误。 + +## Verification + +- `pnpm exec vitest run tests/unit/auth-routes.test.ts tests/unit/works-square-session-persistence.test.ts tests/unit/works-square-session.test.ts tests/unit/auth-store.test.ts tests/unit/login-page.test.tsx tests/unit/works-square-design-workspace.test.ts` + - 通过:6 files,85 tests。 +- `pnpm run typecheck` + - 通过。 +- 针对本次变更文件运行 ESLint + - 通过。 +- `pnpm run build:vite` + - 通过;仅保留既有 chunk size/dynamic import 警告。 +- 全量 `npm test -- --reporter=dot` + - 未全绿:21 项失败集中在隔离工作树缺少外部 `zip`、未检出的 `.opencode/agent` 资源及既有 + project progress sync 用例;认证聚焦测试全部通过。 + +## Follow-ups + +- 服务端与客户端需协调上线;先发布支持旋转凭据原子落盘的兼容版本并确认共享 `app` 客户端的 + 所有使用方,再应用 OAuth 数据迁移并刷新 RegisteredClient Redis 缓存。 + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index c4a969f..ebff66e 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ pnpm run package:linux - Renderer 的后端调用统一经过 `src/lib/host-api.ts` 或 `src/lib/api-client.ts`。 - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。 +- Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在系统提供受保护凭据存储时加密落盘;Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话保持本地。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、可跨异常重启幂等复用的 Agent Session、单次 WebSocket ticket、断点续传与事件契约映射,并通过本机 Host API 的 SSE 投影同步任务状态;注销/退出时回收 Session,远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index 8bce6b7..bf8ea8b 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -8,12 +8,21 @@ import { NIANCODE_AUTH_CONFIG } from '../auth-config'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { clearWorksSquareSession, - storeWorksSquareSession, - storeWorksSquareSessionFromTokenPayload, + commitWorksSquareSession, + commitWorksSquareSessionFromTokenPayload, + discardUnrestorableWorksSquareSession, + flushWorksSquareSessionPersistence, + getValidWorksSquareAccessToken, + getWorksSquareSessionRestoreStatus, + getWorksSquareSessionSnapshot, + markWorksSquareSessionActive, + retryWorksSquareSessionRestore, } from '../../services/works-square-session'; -import { clearWorksSquareAIGatewayCredential } from '../../services/works-square-ai-gateway'; -import { getProviderService } from '../../services/providers/provider-service'; -import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../../shared/user-model-config'; +import { + clearManagedWorksSquareRuntime, + clearManagedWorksSquareRuntimeBestEffort, + ensureManagedWorksSquareRuntimeClean, +} from '../../services/works-square-runtime'; import { logger } from '../../utils/logger'; import type { WorksSquareTokenPayload } from '../../services/works-square-session'; @@ -31,10 +40,6 @@ type PasswordLoginInput = AuthClientInput & { scope?: unknown; }; -type RefreshInput = AuthClientInput & { - refreshToken?: unknown; -}; - type LogoutInput = { authBase?: unknown; accessToken?: unknown; @@ -45,6 +50,11 @@ type SessionSyncInput = { refreshToken?: unknown; tokenType?: unknown; expiresAt?: unknown; + lastActiveAt?: unknown; +}; + +type SessionRefreshInput = { + forceRefresh?: unknown; }; type DesktopAuthStartPayload = { @@ -85,6 +95,16 @@ function readOptionalNumber(value: unknown): number | 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 normalizeAuthBase(value: unknown = NIANCODE_AUTH_CONFIG.gatewayAuthUrl): string { const authBase = readOptionalString(value, NIANCODE_AUTH_CONFIG.gatewayAuthUrl).replace(/\/+$/, ''); if (!/^https?:\/\//i.test(authBase)) { @@ -231,7 +251,11 @@ async function pollDesktopAuthToken( throw new Error('Authorization timed out'); } -async function handleBrowserAuthorization(res: ServerResponse): Promise { +async function handleBrowserAuthorization( + res: ServerResponse, + ctx: HostApiContext, +): Promise { + if (!await ensureRuntimeReadyForLogin(res, ctx)) return; const response = await proxyAwareFetch(createWorksUrl('/api/auth/desktop/start').toString(), { method: 'POST', }); @@ -257,13 +281,19 @@ async function handleBrowserAuthorization(res: ServerResponse): Promise { await shell.openExternal(authorizeUrl); const token = await pollDesktopAuthToken(requestId, deviceSecret, pollIntervalMs); + let session = null; if (token && typeof token === 'object' && !Array.isArray(token)) { - storeWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload); + session = await commitWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload); } - sendJson(res, 200, { success: true, token }); + sendJson(res, 200, { success: true, token: withoutRefreshToken(token), session }); } -async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): Promise { +async function handlePasswordLogin( + req: IncomingMessage, + res: ServerResponse, + ctx: HostApiContext, +): Promise { + if (!await ensureRuntimeReadyForLogin(res, ctx)) return; const body = await parseJsonBody(req); const authBase = normalizeAuthBase(body.authBase); const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId); @@ -303,91 +333,171 @@ async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): P return; } + let session = null; if ( tokenResult.payload && typeof tokenResult.payload === 'object' && !Array.isArray(tokenResult.payload) ) { - storeWorksSquareSessionFromTokenPayload(tokenResult.payload as WorksSquareTokenPayload); + session = await commitWorksSquareSessionFromTokenPayload( + tokenResult.payload as WorksSquareTokenPayload, + ); } - sendJson(res, 200, { success: true, token: tokenResult.payload }); + sendJson(res, 200, { + success: true, + token: withoutRefreshToken(tokenResult.payload), + session, + }); } -async function handleRefresh(req: IncomingMessage, res: ServerResponse): Promise { - const body = await parseJsonBody(req); - const authBase = normalizeAuthBase(body.authBase); - const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId); - const clientSecret = readOptionalString(body.clientSecret, NIANCODE_AUTH_CONFIG.clientSecret); - const refreshToken = readRequiredString(body.refreshToken, 'refreshToken'); - - const tokenResult = await requestToken( - authBase, - clientId, - clientSecret, - createTokenRequestBody({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }), - ); - - if (!tokenResult.ok) { - sendJson(res, tokenResult.status === 401 ? 401 : 502, { - success: false, - error: getErrorMessage(tokenResult.payload, `Refresh failed (${tokenResult.status})`), - }); +async function handleSessionSync( + req: IncomingMessage, + res: ServerResponse, + ctx: HostApiContext, +): Promise { + if (!await ensureWorksSquareSessionRestored()) { + sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' }); return; } - - if ( - tokenResult.payload - && typeof tokenResult.payload === 'object' - && !Array.isArray(tokenResult.payload) - ) { - storeWorksSquareSessionFromTokenPayload(tokenResult.payload as WorksSquareTokenPayload, refreshToken); + try { + await flushWorksSquareSessionPersistence(); + } catch (error) { + logger.warn('[auth] Session persistence is temporarily unavailable during sync', error); + sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' }); + return; } - - sendJson(res, 200, { success: true, token: tokenResult.payload }); -} - -async function handleSessionSync(req: IncomingMessage, res: ServerResponse): Promise { const body = await parseJsonBody(req); - storeWorksSquareSession({ - accessToken: readRequiredString(body.accessToken, 'accessToken'), - refreshToken: readOptionalTrimmedString(body.refreshToken), - tokenType: readOptionalTrimmedString(body.tokenType), - expiresAt: readOptionalNumber(body.expiresAt), - }); + 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; + } + } - sendJson(res, 200, { success: true }); + if (!session) { + sendJson(res, 200, { success: true, session: null }); + return; + } + sendJson(res, 200, { success: true, session }); } -async function clearManagedWorksSquareRuntime(ctx: HostApiContext): Promise { - const errors: string[] = []; - +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 { - await ctx.imageWorkspace?.closeEventSessions?.(); + 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) { - errors.push(error instanceof Error ? error.message : String(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 { - await ctx.opencodeManager.stop(); + session = await markWorksSquareSessionActive(); } catch (error) { - errors.push(error instanceof Error ? error.message : String(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, + }); +} - clearWorksSquareAIGatewayCredential(); +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 { - await getProviderService().deleteAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID); + if (discardedUnrestorableSession) { + await clearManagedWorksSquareRuntime(ctx, undefined, true); + } + await ensureManagedWorksSquareRuntimeClean(ctx); + return true; } catch (error) { - errors.push(error instanceof Error ? error.message : String(error)); + logger.error('[auth] Failed to clean the previous runtime before login', error); + sendJson(res, 503, { + success: false, + error: '上次登录的本地运行环境尚未清理完成,请稍后重试。', + }); + return false; } +} - if (errors.length > 0) { - throw new Error(`Failed to clear managed Works Square runtime state: ${errors.join('; ')}`); - } +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( @@ -397,16 +507,24 @@ async function handleLogout( ): Promise { const body = await parseJsonBody(req); const authBase = normalizeAuthBase(body.authBase); - const accessToken = readRequiredString(body.accessToken, 'accessToken'); + const rendererAccessToken = readOptionalTrimmedString(body.accessToken); + const accessToken = getWorksSquareSessionSnapshot()?.accessToken + ?? readRequiredString(rendererAccessToken, 'accessToken'); let cleanupError: Error | null = null; + clearWorksSquareSession(); try { - await clearManagedWorksSquareRuntime(ctx); + 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); } - clearWorksSquareSession(); + 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 { @@ -459,22 +577,32 @@ export async function handleAuthRoutes( try { if (url.pathname === '/api/auth/browser/start' && req.method === 'POST') { - await handleBrowserAuthorization(res); + await handleBrowserAuthorization(res, ctx); return true; } if (url.pathname === '/api/auth/login' && req.method === 'POST') { - await handlePasswordLogin(req, res); - return true; - } - - if (url.pathname === '/api/auth/refresh' && req.method === 'POST') { - await handleRefresh(req, res); + await handlePasswordLogin(req, res, ctx); return true; } if (url.pathname === '/api/auth/session/sync' && req.method === 'POST') { - await handleSessionSync(req, res); + 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/session/clear' && req.method === 'POST') { + await handleSessionClear(res, ctx); return true; } diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 098dc60..58b90c3 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -22,6 +22,11 @@ export type DesignWorkspaceEventSubscriptionInput = { afterEventId?: string; }; +export type CloseEventSessionsOptions = { + accessToken?: string; + tolerateRemoteFailure?: boolean; +}; + export class DesignWorkspaceModuleError extends Error { readonly status: number; readonly code: string; @@ -47,7 +52,7 @@ export interface DesignWorkspaceModule { openWorkspaceEvents?( input: DesignWorkspaceEventSubscriptionInput, ): Promise; - closeEventSessions?(): Promise; + closeEventSessions?(options?: CloseEventSessionsOptions): Promise; openAssetContent(workspaceId: string, assetId: string, range?: string): Promise; reset?(): Promise; } diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index d7fabbd..b4e6937 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -26,6 +26,7 @@ import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from '../services/works-square-session'; import { DesignWorkspaceModuleError, + type CloseEventSessionsOptions, type DesignWorkspaceEventSubscription, type DesignWorkspaceEventSubscriptionInput, type DesignWorkspaceModule, @@ -1114,7 +1115,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }; } - async closeEventSessions(): Promise { + async closeEventSessions(options: CloseEventSessionsOptions = {}): Promise { this.eventSessionsEnabled = false; const activeSubscriptions = [...this.eventSubscriptionClosers.values()] .flatMap((closers) => [...closers]); @@ -1122,27 +1123,42 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { for (const close of activeSubscriptions) close(); const pendingSessions = [...this.eventSessions.entries()]; this.eventSessions.clear(); - const sessions = await Promise.allSettled( - pendingSessions.map(async ([workspaceId, pending]) => ({ - workspaceId, - session: await pending, - })), - ); - const closeResults = await Promise.allSettled( - sessions.flatMap((result) => ( - result.status === 'fulfilled' - ? [this.closeEventSession(result.value.session.session_id) - .then(() => this.rotateEventSession(result.value.workspaceId))] - : [] - )), - ); - const uncertainCreations = sessions.filter((result) => ( - result.status === 'rejected' - && !(result.reason instanceof DesignWorkspaceModuleError - && result.reason.code === 'DESIGN_EVENT_SESSION_CLOSED') + const sessions = await Promise.all(pendingSessions.map(async ([workspaceId, pending]) => { + try { + return { workspaceId, session: await pending, creationError: null as unknown }; + } catch (error) { + return { workspaceId, session: null, creationError: error }; + } + })); + const closeResults = await Promise.all(sessions.map(async ({ + workspaceId, + session, + creationError, + }) => { + let remoteError = creationError; + if (session) { + try { + await this.closeEventSession(session.session_id, options.accessToken); + } catch (error) { + remoteError = error; + } + } + + let rotationError: unknown = null; + try { + await this.rotateEventSession(workspaceId); + } catch (error) { + rotationError = error; + } + return { remoteError, rotationError }; + })); + const failed = closeResults.filter(({ remoteError, rotationError }) => ( + Boolean(rotationError) + || (!options.tolerateRemoteFailure + && Boolean(remoteError) + && !(remoteError instanceof DesignWorkspaceModuleError + && remoteError.code === 'DESIGN_EVENT_SESSION_CLOSED')) )).length; - const failedCloses = closeResults.filter((result) => result.status === 'rejected').length; - const failed = uncertainCreations + failedCloses; if (failed > 0) { throw new Error(`Failed to close ${failed} AI design Agent Session(s)`); } @@ -1155,7 +1171,11 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { ); } - private async requestJson(path: string, init: RequestInit = {}): Promise { + private async requestJson( + path: string, + init: RequestInit = {}, + accessToken?: string, + ): Promise { const response = await this.authorizedFetch(path, { ...init, headers: { @@ -1165,7 +1185,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { : {}), ...(init.headers ?? {}), }, - }); + }, accessToken); const payload = await readPayload(response); if (!response.ok) { const detail = asErrorDetail(payload); @@ -1184,7 +1204,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { return payload as T; } - private async authorizedFetch(path: string, init: RequestInit = {}): Promise { + private async authorizedFetch( + path: string, + init: RequestInit = {}, + accessToken?: string, + ): Promise { + if (accessToken) return this.fetchWithToken(path, accessToken, init); let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl }); if (!token) { throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计'); @@ -1314,11 +1339,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { for (const resolve of waiters) resolve(run); } - private async closeEventSession(sessionId: string): Promise { + private async closeEventSession(sessionId: string, accessToken?: string): Promise { try { await this.requestJson( `/api/agents/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }, + accessToken, ); } catch (error) { if (error instanceof DesignWorkspaceModuleError diff --git a/electron/main/index.ts b/electron/main/index.ts index 5fce5e4..31552e6 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -70,6 +70,15 @@ import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browse import { browserOAuthManager } from '../utils/browser-oauth'; import { createProjectProgressSync } from '../services/project-progress-sync'; import { createWorksCloudDeployment } from '../services/works-cloud-deployment'; +import { + consumeWorksSquareStartupRuntimeCleanupRequired, + getWorksSquareSessionRestoreStatus, + getWorksSquareSessionSnapshot, + initializeWorksSquareSession, + subscribeWorksSquareSession, + type WorksSquareSessionChangeReason, +} from '../services/works-square-session'; +import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime'; import { getPort } from '../utils/config'; import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets'; import { @@ -318,6 +327,26 @@ function registerMakeloreProtocolClient(): void { function createMainWindow(): BrowserWindow { const win = createWindow(); + const sendAuthSession = ( + session: ReturnType, + reason: WorksSquareSessionChangeReason = 'changed', + previousSession: ReturnType = null, + ) => { + if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return; + if (!win.isDestroyed() && !win.webContents.isDestroyed()) { + win.webContents.send('auth:session-changed', session); + } + if (!session && reason === 'terminal') { + void clearManagedWorksSquareRuntimeBestEffort({ + opencodeManager, + imageWorkspace: imageWorkspaceModule ?? undefined, + }, 'terminal session invalidation', previousSession?.accessToken); + } + }; + const unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession); + win.webContents.on('did-finish-load', () => { + sendAuthSession(getWorksSquareSessionSnapshot()); + }); const closeAgentBrowserForHostRenderer = (reason: string): void => { void agentBrowser?.close().catch((error) => { @@ -358,6 +387,7 @@ function createMainWindow(): BrowserWindow { }); win.on('closed', () => { + unsubscribeAuthSession(); const browser = agentBrowser; agentBrowser = null; void browser?.dispose().catch((error) => { @@ -409,14 +439,15 @@ async function initialize(): Promise { logger.info('Running in E2E mode: startup side effects minimized'); } + // Restore authentication before any background service or renderer can request a token. + await initializeWorksSquareSession(); + opencodeProjectStore = createProjectStore(await createElectronProjectStorage()); if (!isE2EMode) { projectProgressSync = createProjectProgressSync(opencodeProjectStore); - void projectProgressSync.start(); } worksCloudDeployment = createWorksCloudDeployment(opencodeProjectStore); - if (!isE2EMode) void worksCloudDeployment.start(); const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: app.isPackaged, @@ -432,6 +463,12 @@ async function initialize(): Promise { }, }); imageWorkspaceModule = imageWorkspace; + if (consumeWorksSquareStartupRuntimeCleanupRequired()) { + await clearManagedWorksSquareRuntimeBestEffort({ + opencodeManager, + imageWorkspace, + }, 'expired persisted session during startup'); + } if (localImageWorkspaceEnabled) { logger.info('AI painting workspace is using local development storage'); } else { @@ -444,6 +481,10 @@ async function initialize(): Promise { // Create the main window const window = createMainWindow(); agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window)); + if (!isE2EMode) { + void projectProgressSync?.start(); + void worksCloudDeployment.start(); + } // Create system tray if (!isE2EMode) { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c851c11..7c2950a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -80,6 +80,7 @@ const validEventChannels = [ 'oauth:error', 'agent-browser:show', 'agent-browser:state', + 'auth:session-changed', ]; /** diff --git a/electron/services/works-square-runtime.ts b/electron/services/works-square-runtime.ts new file mode 100644 index 0000000..edb7553 --- /dev/null +++ b/electron/services/works-square-runtime.ts @@ -0,0 +1,103 @@ +import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../shared/user-model-config'; +import { logger } from '../utils/logger'; +import { clearWorksSquareAIGatewayCredential } from './works-square-ai-gateway'; +import { getProviderService } from './providers/provider-service'; + +export type WorksSquareRuntimeContext = { + opencodeManager: { + stop(): Promise; + }; + imageWorkspace?: { + closeEventSessions?(options?: { + accessToken?: string; + tolerateRemoteFailure?: boolean; + }): Promise; + }; +}; + +let cleanupFlight: Promise | null = null; +let cleanupRequired = false; + +async function runManagedWorksSquareRuntimeCleanup( + ctx: WorksSquareRuntimeContext, + accessToken?: string, + tolerateRemoteFailure = false, +): Promise { + // Revoke the in-memory derived credential synchronously; slower resource cleanup follows. + clearWorksSquareAIGatewayCredential(); + + const results = await Promise.allSettled([ + (async () => await ctx.imageWorkspace?.closeEventSessions?.({ + accessToken, + tolerateRemoteFailure, + }))(), + (async () => await ctx.opencodeManager.stop())(), + (async () => { + const deleted = await getProviderService().deleteAccountApiKey( + NIANCODE_USER_MODEL_ACCOUNT_ID, + ); + if (!deleted) throw new Error('provider API key storage rejected deletion'); + })(), + ]); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => ( + result.reason instanceof Error ? result.reason.message : String(result.reason) + )); + + if (errors.length > 0) { + throw new Error(`Failed to clear managed Works Square runtime state: ${errors.join('; ')}`); + } +} + +export function clearManagedWorksSquareRuntime( + ctx: WorksSquareRuntimeContext, + accessToken?: string, + tolerateRemoteFailure = false, +): Promise { + if (cleanupFlight) return cleanupFlight; + cleanupRequired = true; + const flight = runManagedWorksSquareRuntimeCleanup( + ctx, + accessToken, + tolerateRemoteFailure, + ) + .then(() => { + cleanupRequired = false; + }) + .finally(() => { + if (cleanupFlight === flight) cleanupFlight = null; + }); + cleanupFlight = flight; + return flight; +} + +export async function ensureManagedWorksSquareRuntimeClean( + ctx: WorksSquareRuntimeContext, +): Promise { + if (cleanupFlight) { + try { + await cleanupFlight; + } catch { + // Retry below with the current runtime context. + } + } + if (cleanupRequired) await clearManagedWorksSquareRuntime(ctx); +} + +export async function clearManagedWorksSquareRuntimeBestEffort( + ctx: WorksSquareRuntimeContext, + reason: string, + accessToken?: string, +): Promise { + try { + await clearManagedWorksSquareRuntime(ctx, accessToken, true); + } catch (error) { + logger.error(`[auth] Failed to clear managed runtime after ${reason}`, error); + } +} + +export function resetManagedWorksSquareRuntimeForTests(): void { + cleanupFlight = null; + cleanupRequired = false; +} diff --git a/electron/services/works-square-session.ts b/electron/services/works-square-session.ts index feeffd4..cd5814d 100644 --- a/electron/services/works-square-session.ts +++ b/electron/services/works-square-session.ts @@ -1,14 +1,18 @@ import { NIANCODE_AUTH_CONFIG } from '../api/auth-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { logger } from '../utils/logger'; +import { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session'; const TOKEN_REFRESH_SKEW_MS = 30_000; +const SESSION_STORE_SCHEMA_VERSION = 1; +export { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session'; export type WorksSquareSessionInput = { accessToken: string; refreshToken?: string | null; tokenType?: string | null; expiresAt?: number | null; + lastActiveAt?: number | null; }; export type WorksSquareTokenPayload = { @@ -19,29 +23,51 @@ export type WorksSquareTokenPayload = { }; export type WorksSquareSessionSnapshot = { + accessToken: string; + tokenType: string; + expiresAt: number | null; + lastActiveAt: number; + canRefresh: boolean; +}; + +type StoredWorksSquareSession = { accessToken: string; refreshToken: string | null; tokenType: string; expiresAt: number | null; + lastActiveAt: number; }; -export type WorksSquareSessionListener = (session: WorksSquareSessionSnapshot | null) => void; - -let currentSession: WorksSquareSessionSnapshot | null = null; -let refreshPromise: Promise | null = null; -const sessionListeners = new Set(); - -function notifySessionListeners(): void { - const snapshot = currentSession ? { ...currentSession } : null; - for (const listener of sessionListeners) { - try { - listener(snapshot); - } catch { - // Session observers must not affect authentication state changes. - } - } +export interface WorksSquareSessionPersistence { + discardedInvalidRecord?: boolean; + load(): Promise; + save(session: WorksSquareSessionInput | null): Promise; } +export type WorksSquareSessionChangeReason = 'changed' | 'terminal'; +export type WorksSquareSessionListener = ( + session: WorksSquareSessionSnapshot | null, + reason: WorksSquareSessionChangeReason, + previousSession?: WorksSquareSessionSnapshot | null, +) => void; +export type WorksSquareSessionRestoreStatus = 'ready' | 'unavailable'; + +type RefreshFlight = { + generation: number; + promise: Promise; +}; + +let currentSession: StoredWorksSquareSession | null = null; +let credentialGeneration = 0; +let refreshFlight: RefreshFlight | null = null; +let sessionPersistence: WorksSquareSessionPersistence | null = null; +let sessionPersistenceFactory: (() => Promise) | null = null; +let persistenceQueue: Promise = Promise.resolve(); +let credentialPersistenceBarrier: Promise | null = null; +let restoreStatus: WorksSquareSessionRestoreStatus = 'ready'; +let startupRuntimeCleanupRequired = false; +const sessionListeners = new Set(); + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } @@ -57,11 +83,57 @@ function createBasicAuthHeader(clientId: string, clientSecret: string): string { return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; } +function toPublicSnapshot( + session: StoredWorksSquareSession | null, +): WorksSquareSessionSnapshot | null { + if (!session) return null; + return { + accessToken: session.accessToken, + tokenType: session.tokenType, + expiresAt: session.expiresAt, + lastActiveAt: session.lastActiveAt, + canRefresh: Boolean(session.refreshToken), + }; +} + +function toPersistenceInput( + session: StoredWorksSquareSession | null, +): WorksSquareSessionInput | null { + return session ? { ...session } : null; +} + +function normalizeSession( + input: WorksSquareSessionInput, + nowMs = Date.now(), + requireLastActiveAt = false, +): StoredWorksSquareSession | null { + const accessToken = asString(input.accessToken); + if (!accessToken) return null; + if ( + requireLastActiveAt + && (typeof input.lastActiveAt !== 'number' || !Number.isFinite(input.lastActiveAt)) + ) { + return null; + } + return { + accessToken, + refreshToken: asString(input.refreshToken), + tokenType: asString(input.tokenType) ?? 'Bearer', + expiresAt: typeof input.expiresAt === 'number' && Number.isFinite(input.expiresAt) + ? input.expiresAt + : null, + lastActiveAt: typeof input.lastActiveAt === 'number' && Number.isFinite(input.lastActiveAt) + ? Math.min(input.lastActiveAt, nowMs) + : nowMs, + }; +} + function parseTokenPayload( payload: WorksSquareTokenPayload, fallbackRefreshToken: string | null, nowMs = Date.now(), -): WorksSquareSessionSnapshot { + lastActiveAt = nowMs, +): StoredWorksSquareSession { const accessToken = asString(payload.access_token); if (!accessToken) { throw new Error('Auth refresh response did not include access_token'); @@ -72,9 +144,217 @@ function parseTokenPayload( refreshToken: asString(payload.refresh_token) ?? fallbackRefreshToken, tokenType: asString(payload.token_type) ?? 'Bearer', expiresAt: expiresAtFromExpiresIn(payload.expires_in, nowMs), + lastActiveAt, }; } +function isSessionIdle(session: StoredWorksSquareSession, nowMs: number): boolean { + return nowMs - session.lastActiveAt >= WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS; +} + +function notifySessionListeners( + reason: WorksSquareSessionChangeReason = 'changed', + previousSession: WorksSquareSessionSnapshot | null = null, +): void { + const snapshot = toPublicSnapshot(currentSession); + for (const listener of sessionListeners) { + try { + listener( + snapshot ? { ...snapshot } : null, + reason, + previousSession ? { ...previousSession } : null, + ); + } catch { + // Session observers must not affect authentication state changes. + } + } +} + +function queuePersistence(session: StoredWorksSquareSession | null): Promise { + const persistence = sessionPersistence; + if (!persistence) return Promise.resolve(); + const snapshot = toPersistenceInput(session); + const operation = persistenceQueue + .catch(() => undefined) + .then(() => persistence.save(snapshot)); + persistenceQueue = operation; + return operation; +} + +function replaceCredentialSession( + session: StoredWorksSquareSession | null, + notify = true, +): { + generation: number; + persisted: Promise; + previousSession: WorksSquareSessionSnapshot | null; + } { + const previousSession = toPublicSnapshot(currentSession); + currentSession = session; + credentialGeneration += 1; + refreshFlight = null; + const generation = credentialGeneration; + const persisted = queuePersistence(session); + if (sessionPersistence) { + credentialPersistenceBarrier = persisted; + void persisted.then( + () => { + if (credentialPersistenceBarrier === persisted) credentialPersistenceBarrier = null; + }, + () => { + if (credentialPersistenceBarrier === persisted) credentialPersistenceBarrier = null; + }, + ); + } + if (notify) notifySessionListeners('changed', previousSession); + return { generation, persisted, previousSession }; +} + +async function createElectronSessionPersistence(): Promise { + const [{ default: Store }, { safeStorage }] = await Promise.all([ + import('electron-store'), + import('electron'), + ]); + const usesUnprotectedLinuxBackend = process.platform === 'linux' + && safeStorage.getSelectedStorageBackend() === 'basic_text'; + if (!safeStorage.isEncryptionAvailable() || usesUnprotectedLinuxBackend) { + logger.warn('[works-square-session] OS credential encryption is unavailable; session restore disabled'); + return null; + } + + type EncryptedSessionRecord = { + version: number; + authBase: string; + ciphertext: string; + }; + const storeOptions = { + name: 'works-square-session', + configFileMode: 0o600, + } as const; + let discardedInvalidRecord = false; + let store: Store<{ record?: EncryptedSessionRecord }>; + try { + store = new Store<{ record?: EncryptedSessionRecord }>(storeOptions); + } catch (error) { + logger.warn('[works-square-session] Invalid session store config; discarding it', error); + store = new Store<{ record?: EncryptedSessionRecord }>({ + ...storeOptions, + clearInvalidConfig: true, + }); + discardedInvalidRecord = true; + } + + return { + discardedInvalidRecord, + async load() { + const record = store.get('record'); + if (!record) return null; + if ( + record.version !== SESSION_STORE_SCHEMA_VERSION + || record.authBase !== NIANCODE_AUTH_CONFIG.gatewayAuthUrl + ) { + store.delete('record'); + return null; + } + return JSON.parse( + safeStorage.decryptString(Buffer.from(record.ciphertext, 'base64')), + ) as WorksSquareSessionInput; + }, + async save(session) { + if (!session) { + store.delete('record'); + return; + } + const encrypted = safeStorage.encryptString(JSON.stringify(session)); + store.set('record', { + version: SESSION_STORE_SCHEMA_VERSION, + authBase: NIANCODE_AUTH_CONFIG.gatewayAuthUrl, + ciphertext: encrypted.toString('base64'), + }); + }, + }; +} + +export async function initializeWorksSquareSession( + options: { + persistence?: WorksSquareSessionPersistence; + nowMs?: number; + } = {}, +): Promise { + persistenceQueue = Promise.resolve(); + credentialPersistenceBarrier = null; + startupRuntimeCleanupRequired = false; + sessionPersistenceFactory = options.persistence ? null : createElectronSessionPersistence; + try { + sessionPersistence = options.persistence + ?? await sessionPersistenceFactory!(); + } catch (error) { + logger.warn('[works-square-session] Failed to initialize secure session persistence', error); + sessionPersistence = null; + restoreStatus = 'unavailable'; + startupRuntimeCleanupRequired = true; + currentSession = null; + credentialGeneration += 1; + refreshFlight = null; + return null; + } + startupRuntimeCleanupRequired = Boolean(sessionPersistence?.discardedInvalidRecord); + + if (!sessionPersistence) { + restoreStatus = 'ready'; + currentSession = null; + credentialGeneration += 1; + refreshFlight = null; + return null; + } + + let restored: StoredWorksSquareSession | null; + let hadPersistedSession: boolean; + try { + const persisted = await sessionPersistence.load(); + hadPersistedSession = Boolean(persisted); + restored = persisted + ? normalizeSession(persisted, options.nowMs ?? Date.now(), true) + : null; + } catch (error) { + logger.warn('[works-square-session] Failed to restore the persisted session', error); + restoreStatus = 'unavailable'; + startupRuntimeCleanupRequired = true; + currentSession = null; + credentialGeneration += 1; + refreshFlight = null; + return null; + } + + const nowMs = options.nowMs ?? Date.now(); + if (restored && !isSessionIdle(restored, nowMs)) { + restoreStatus = 'ready'; + currentSession = restored; + credentialGeneration += 1; + refreshFlight = null; + return toPublicSnapshot(restored); + } + + if (!hadPersistedSession) { + restoreStatus = 'ready'; + currentSession = null; + credentialGeneration += 1; + refreshFlight = null; + return null; + } + + startupRuntimeCleanupRequired = hadPersistedSession; + restoreStatus = 'ready'; + const cleared = replaceCredentialSession(null, false); + try { + await cleared.persisted; + } catch (error) { + restoreStatus = 'unavailable'; + logger.warn('[works-square-session] Failed to clear an invalid persisted session', error); + } + return null; +} + async function readResponsePayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; @@ -86,38 +366,84 @@ async function readResponsePayload(response: Response): Promise { } export function storeWorksSquareSession(input: WorksSquareSessionInput): void { - const accessToken = input.accessToken.trim(); - if (!accessToken) { + const session = normalizeSession(input); + if (!session || isSessionIdle(session, Date.now())) { clearWorksSquareSession(); return; } + replaceCredentialSession(session); +} - currentSession = { - accessToken, - refreshToken: input.refreshToken?.trim() || null, - tokenType: input.tokenType?.trim() || 'Bearer', - expiresAt: typeof input.expiresAt === 'number' && Number.isFinite(input.expiresAt) - ? input.expiresAt - : null, - }; - notifySessionListeners(); +export async function commitWorksSquareSession( + input: WorksSquareSessionInput, +): Promise { + const session = normalizeSession(input); + if (!session || isSessionIdle(session, Date.now())) { + await clearWorksSquareSessionPersisted('terminal'); + return null; + } + const update = replaceCredentialSession(session, false); + try { + await update.persisted; + } catch (error) { + await failClosedAfterPersistenceError(update.generation, error); + throw error; + } + restoreStatus = 'ready'; + if (credentialGeneration !== update.generation) return null; + notifySessionListeners('changed', update.previousSession); + return getWorksSquareSessionSnapshot(); } export function storeWorksSquareSessionFromTokenPayload( payload: WorksSquareTokenPayload, fallbackRefreshToken: string | null = null, nowMs = Date.now(), + lastActiveAt = nowMs, ): WorksSquareSessionSnapshot { - const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs); - currentSession = session; - notifySessionListeners(); - return session; + const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs, lastActiveAt); + replaceCredentialSession(session); + return toPublicSnapshot(session)!; +} + +export async function commitWorksSquareSessionFromTokenPayload( + payload: WorksSquareTokenPayload, + fallbackRefreshToken: string | null = null, + nowMs = Date.now(), + lastActiveAt = nowMs, +): Promise { + const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs, lastActiveAt); + const update = replaceCredentialSession(session, false); + try { + await update.persisted; + } catch (error) { + await failClosedAfterPersistenceError(update.generation, error); + throw error; + } + restoreStatus = 'ready'; + if (credentialGeneration !== update.generation) { + throw new Error('Session changed while credentials were being persisted'); + } + notifySessionListeners('changed', update.previousSession); + return toPublicSnapshot(session)!; } export function clearWorksSquareSession(): void { - currentSession = null; - refreshPromise = null; - notifySessionListeners(); + replaceCredentialSession(null); +} + +async function clearWorksSquareSessionPersisted( + reason: WorksSquareSessionChangeReason = 'changed', +): Promise { + const update = replaceCredentialSession(null, false); + if (credentialGeneration === update.generation) { + notifySessionListeners(reason, update.previousSession); + } + await update.persisted; +} + +export async function flushWorksSquareSessionPersistence(): Promise { + await persistenceQueue; } export function subscribeWorksSquareSession(listener: WorksSquareSessionListener): () => void { @@ -128,14 +454,169 @@ export function subscribeWorksSquareSession(listener: WorksSquareSessionListener } export function getWorksSquareSessionSnapshot(): WorksSquareSessionSnapshot | null { - return currentSession ? { ...currentSession } : null; + const snapshot = toPublicSnapshot(currentSession); + return snapshot ? { ...snapshot } : null; +} + +export function getWorksSquareSessionRestoreStatus(): WorksSquareSessionRestoreStatus { + return restoreStatus; +} + +export function consumeWorksSquareStartupRuntimeCleanupRequired(): boolean { + const required = startupRuntimeCleanupRequired; + startupRuntimeCleanupRequired = false; + return required; +} + +export async function retryWorksSquareSessionRestore( + nowMs = Date.now(), +): Promise { + if (restoreStatus !== 'unavailable') { + return getWorksSquareSessionSnapshot(); + } + + if (!sessionPersistence && sessionPersistenceFactory) { + try { + sessionPersistence = await sessionPersistenceFactory(); + startupRuntimeCleanupRequired ||= Boolean(sessionPersistence?.discardedInvalidRecord); + } catch (error) { + logger.warn('[works-square-session] Secure session persistence is still unavailable', error); + return null; + } + } + if (!sessionPersistence) { + restoreStatus = 'ready'; + return null; + } + + let restored: StoredWorksSquareSession | null; + let hadPersistedSession: boolean; + try { + const persisted = await sessionPersistence.load(); + hadPersistedSession = Boolean(persisted); + restored = persisted ? normalizeSession(persisted, nowMs, true) : null; + } catch (error) { + logger.warn('[works-square-session] Persisted session restore is still unavailable', error); + startupRuntimeCleanupRequired = true; + return null; + } + + if (restored && !isSessionIdle(restored, nowMs)) { + restoreStatus = 'ready'; + currentSession = restored; + credentialGeneration += 1; + refreshFlight = null; + notifySessionListeners(); + return getWorksSquareSessionSnapshot(); + } + + if (!hadPersistedSession) { + restoreStatus = 'ready'; + return null; + } + + restoreStatus = 'ready'; + try { + await clearWorksSquareSessionPersisted('terminal'); + restoreStatus = 'ready'; + } catch (error) { + restoreStatus = 'unavailable'; + logger.warn('[works-square-session] Failed to clear an unusable persisted session', error); + } + return null; +} + +export async function discardUnrestorableWorksSquareSession(): Promise { + if (restoreStatus !== 'unavailable') return true; + const persistence = sessionPersistence; + if (!persistence) return false; + + const cleared = replaceCredentialSession(null, false); + try { + await cleared.persisted; + } catch (error) { + restoreStatus = 'unavailable'; + logger.warn('[works-square-session] Failed to discard the unrestorable session', error); + return false; + } + restoreStatus = 'ready'; + startupRuntimeCleanupRequired = true; + if (credentialGeneration === cleared.generation) { + notifySessionListeners('terminal', cleared.previousSession); + } + return true; +} + +export async function markWorksSquareSessionActive( + nowMs = Date.now(), +): Promise { + const session = currentSession; + if (!session) return null; + if (isSessionIdle(session, nowMs)) { + await clearWorksSquareSessionPersisted('terminal'); + return null; + } + + const generation = credentialGeneration; + const previousLastActiveAt = session.lastActiveAt; + currentSession = { ...session, lastActiveAt: nowMs }; + try { + await queuePersistence(currentSession); + } catch (error) { + if ( + credentialGeneration === generation + && currentSession?.lastActiveAt === nowMs + ) { + currentSession = { ...currentSession, lastActiveAt: previousLastActiveAt }; + } + throw error; + } + return getWorksSquareSessionSnapshot(); +} + +async function failClosedAfterPersistenceError( + generation: number, + error: unknown, +): Promise { + logger.error('[works-square-session] Failed to persist rotated credentials; clearing session', error); + if (credentialGeneration !== generation) return; + const cleared = replaceCredentialSession(null, false); + if (credentialGeneration === cleared.generation) { + notifySessionListeners('terminal', cleared.previousSession); + } + try { + await cleared.persisted; + } catch (clearError) { + logger.error('[works-square-session] Failed to persist the fail-closed session clear', clearError); + } +} + +function sessionStillMatches( + session: StoredWorksSquareSession, + generation: number, +): boolean { + return credentialGeneration === generation + && currentSession?.accessToken === session.accessToken + && currentSession.refreshToken === session.refreshToken; +} + +async function waitForCredentialPersistence(): Promise { + const barrier = credentialPersistenceBarrier; + if (!barrier) return true; + try { + await barrier; + return true; + } catch { + return false; + } } async function refreshWorksSquareSession( + session: StoredWorksSquareSession, + generation: number, options: { fetchImpl?: typeof fetch; nowMs?: number } = {}, ): Promise { - const session = currentSession; - if (!session?.refreshToken) return null; + if (!session.refreshToken) return null; const fetchImpl = options.fetchImpl ?? proxyAwareFetch; const nowMs = options.nowMs ?? Date.now(); @@ -157,41 +638,95 @@ async function refreshWorksSquareSession( if (!response.ok) { logger.warn('[works-square-session] Refresh failed', { status: response.status }); - clearWorksSquareSession(); + if ((response.status === 400 || response.status === 401) && sessionStillMatches(session, generation)) { + await clearWorksSquareSessionPersisted('terminal'); + } return null; } const payload = await readResponsePayload(response); if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - clearWorksSquareSession(); + logger.warn('[works-square-session] Refresh returned an invalid payload'); return null; } - const nextSession = storeWorksSquareSessionFromTokenPayload( + if (!sessionStillMatches(session, generation)) { + return null; + } + + const nextSession = parseTokenPayload( payload as WorksSquareTokenPayload, session.refreshToken, nowMs, + currentSession!.lastActiveAt, ); - return nextSession.accessToken; + const update = replaceCredentialSession(nextSession, false); + try { + await update.persisted; + } catch (error) { + await failClosedAfterPersistenceError(update.generation, error); + return null; + } + if (credentialGeneration !== update.generation) return null; + notifySessionListeners('changed', update.previousSession); + return currentSession?.accessToken ?? null; } export async function getValidWorksSquareAccessToken( - options: { fetchImpl?: typeof fetch; nowMs?: number; forceRefresh?: boolean } = {}, + options: { + fetchImpl?: typeof fetch; + nowMs?: number; + forceRefresh?: boolean; + } = {}, ): Promise { + if (restoreStatus === 'unavailable') { + await retryWorksSquareSessionRestore(options.nowMs ?? Date.now()); + if (restoreStatus === 'unavailable') return null; + } + if (credentialPersistenceBarrier && !await waitForCredentialPersistence()) return null; const session = currentSession; if (!session) return null; const nowMs = options.nowMs ?? Date.now(); + if (isSessionIdle(session, nowMs)) { + await clearWorksSquareSessionPersisted('terminal'); + return null; + } const nearExpiry = Boolean( session.expiresAt && session.expiresAt <= nowMs + TOKEN_REFRESH_SKEW_MS, ); if (!nearExpiry && !options.forceRefresh) return session.accessToken; - - if (!refreshPromise) { - refreshPromise = refreshWorksSquareSession(options).finally(() => { - refreshPromise = null; - }); + if (!session.refreshToken) { + await clearWorksSquareSessionPersisted('terminal'); + return null; } - return await refreshPromise; + + const generation = credentialGeneration; + if (refreshFlight?.generation === generation) { + return await refreshFlight.promise; + } + + const flight: RefreshFlight = { + generation, + promise: Promise.resolve(null), + }; + flight.promise = refreshWorksSquareSession(session, generation, options).finally(() => { + if (refreshFlight === flight) refreshFlight = null; + }); + refreshFlight = flight; + return await flight.promise; +} + +export function resetWorksSquareSessionForTests(): void { + currentSession = null; + credentialGeneration += 1; + refreshFlight = null; + sessionPersistence = null; + sessionPersistenceFactory = null; + persistenceQueue = Promise.resolve(); + credentialPersistenceBarrier = null; + restoreStatus = 'ready'; + startupRuntimeCleanupRequired = false; + sessionListeners.clear(); } diff --git a/shared/auth-session.ts b/shared/auth-session.ts new file mode 100644 index 0000000..a4bca39 --- /dev/null +++ b/shared/auth-session.ts @@ -0,0 +1,2 @@ +export const WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS = 7 * 24 * 60 * 60 * 1000; +export const WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS = 60_000; diff --git a/src/App.tsx b/src/App.tsx index 4d05034..6dae9a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { useOpencodeStore } from './stores/opencode'; import { useProjectConfigStore } from './stores/project-config'; import { AI_MODULE_SELECTION_PATH } from './lib/ai-modules'; import { useUserSyncStore } from './stores/user-sync'; +import { subscribeHostEvent } from '@/lib/host-events'; /** @@ -243,6 +244,7 @@ function App() { const initAuth = useAuthStore((state) => state.init); const authInitialized = useAuthStore((state) => state.initialized); const authenticated = useAuthStore((state) => state.isAuthenticated()); + const authAccessToken = useAuthStore((state) => state.accessToken); const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap); const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview; const authRequired = !skipSetupForE2E && !rendererOnlyPreview; @@ -252,6 +254,35 @@ function App() { void initAuth(); }, [initAuth]); + useEffect(() => { + const unsubscribe = subscribeHostEvent('auth:session-changed', (session) => { + useAuthStore.getState().applyMainSession(session); + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (rendererOnlyPreview || !authInitialized || !authAccessToken) return; + + const recordActivity = (event: Event) => { + if (!event.isTrusted) return; + void useAuthStore.getState().markActivity(); + }; + window.addEventListener('pointerdown', recordActivity, { passive: true }); + window.addEventListener('touchstart', recordActivity, { passive: true }); + window.addEventListener('wheel', recordActivity, { passive: true }); + window.addEventListener('keydown', recordActivity); + window.addEventListener('focus', recordActivity); + + return () => { + window.removeEventListener('pointerdown', recordActivity); + window.removeEventListener('touchstart', recordActivity); + window.removeEventListener('wheel', recordActivity); + window.removeEventListener('keydown', recordActivity); + window.removeEventListener('focus', recordActivity); + }; + }, [authAccessToken, authInitialized, rendererOnlyPreview]); + useEffect(() => { initSettings(); }, [initSettings]); diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts index 1a12c34..6e8754f 100644 --- a/src/lib/host-events.ts +++ b/src/lib/host-events.ts @@ -10,6 +10,7 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record = { 'oauth:error': 'oauth:error', 'agent-browser:show': 'agent-browser:show', 'agent-browser:state': 'agent-browser:state', + 'auth:session-changed': 'auth:session-changed', }; function getEventSource(): EventSource { diff --git a/src/stores/auth.ts b/src/stores/auth.ts index ba217cb..9b269b6 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -5,6 +5,10 @@ import { NIANCODE_AUTH_CLIENT_ID, NIANCODE_AUTH_GATEWAY_URL, } from '../../shared/auth-public'; +import { + WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS, + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS, +} from '../../shared/auth-session'; export type AuthUser = { username: string; @@ -16,7 +20,6 @@ export type AuthUser = { type AuthTokenPayload = { access_token?: unknown; - refresh_token?: unknown; token_type?: unknown; expires_in?: unknown; username?: unknown; @@ -31,8 +34,25 @@ type AuthActionResponse = { error?: string; }; +type MainSession = { + accessToken: string; + tokenType: string; + expiresAt: number | null; + lastActiveAt: number; + canRefresh: boolean; +}; + type AuthTokenResponse = AuthActionResponse & { token?: AuthTokenPayload; + session?: MainSession | null; +}; + +type MainSessionResponse = AuthActionResponse & { + session?: MainSession | null; +}; + +type RefreshSessionOptions = { + forceRefresh?: boolean; }; type AuthState = { @@ -42,22 +62,43 @@ type AuthState = { authBase: string; clientId: string; accessToken: string | null; - refreshToken: string | null; tokenType: string | null; expiresAt: number | null; + lastActiveAt: number | null; + canRefresh: boolean; + /** One-release bridge for moving old Renderer-persisted refresh tokens into Main. */ + legacyRefreshToken: string | null; user: AuthUser | null; init: () => Promise; loginWithBrowser: () => Promise; - refreshSession: () => Promise; + refreshSession: (options?: RefreshSessionOptions) => Promise; getValidAccessToken: () => Promise; + markActivity: () => Promise; + maintainSession: () => Promise; + applyMainSession: (session: unknown) => void; invalidateSession: (message?: string) => void; logout: () => Promise; isAuthenticated: () => boolean; }; +type MainSyncResult = + | { kind: 'success'; session: MainSession | null } + | { kind: 'terminal' } + | { kind: 'unavailable' }; + const DEFAULT_CLIENT_ID = NIANCODE_AUTH_CLIENT_ID; const DEFAULT_AUTH_BASE = NIANCODE_AUTH_GATEWAY_URL; const TOKEN_EXPIRY_SKEW_MS = 30_000; +const IDLE_LOGIN_MESSAGE = '登录已超过 7 天未使用,请重新授权。'; +let authSessionEpoch = 0; + +function advanceAuthSessionEpoch(): void { + authSessionEpoch += 1; +} + +function isCurrentAuthSessionEpoch(epoch: number): boolean { + return epoch === authSessionEpoch; +} function trimTrailingSlash(value: string): string { return value.trim().replace(/\/+$/, ''); @@ -77,65 +118,105 @@ function asAuthorities(value: unknown): string[] { return value.filter((item): item is string => typeof item === 'string' && item.length > 0); } -function getExpiresAt(expiresIn: unknown): number | null { - const seconds = typeof expiresIn === 'number' - ? expiresIn - : (typeof expiresIn === 'string' ? Number(expiresIn) : NaN); - if (!Number.isFinite(seconds) || seconds <= 0) return null; - return Date.now() + seconds * 1000; +function createUserFromToken(token: AuthTokenPayload): AuthUser { + return { + username: asString(token.username) ?? '', + userId: asString(token.user_id), + tenantId: asStringOrNumber(token.tenant_id), + deptId: asStringOrNumber(token.dept_id), + authorities: asAuthorities(token.authorities), + }; } -function createSessionFromToken(token: AuthTokenPayload, fallbackRefreshToken: string | null = null) { - const accessToken = asString(token.access_token); - if (!accessToken) { - throw new Error('Login response did not include access_token'); - } +function isSessionIdle(lastActiveAt: number | null, nowMs = Date.now()): boolean { + return lastActiveAt != null + && nowMs - lastActiveAt >= WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS; +} - return { - accessToken, - refreshToken: asString(token.refresh_token) ?? fallbackRefreshToken, - tokenType: asString(token.token_type) ?? 'Bearer', - expiresAt: getExpiresAt(token.expires_in), - user: { - username: asString(token.username) ?? '', - userId: asString(token.user_id), - tenantId: asStringOrNumber(token.tenant_id), - deptId: asStringOrNumber(token.dept_id), - authorities: asAuthorities(token.authorities), - }, +function isTerminalAuthError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const candidate = error as { + code?: unknown; + details?: { status?: unknown }; }; + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return candidate.code === 'AUTH_INVALID' + || candidate.details?.status === 401 + || message.includes('invalid refresh token') + || message.includes('unauthorized'); } function getClearedSession() { return { accessToken: null, - refreshToken: null, tokenType: null, expiresAt: null, + lastActiveAt: null, + canRefresh: false, + legacyRefreshToken: null, user: null, }; } +function sessionFieldsFromMain(session: MainSession) { + return { + accessToken: session.accessToken, + tokenType: session.tokenType, + expiresAt: session.expiresAt, + lastActiveAt: session.lastActiveAt, + canRefresh: session.canRefresh, + legacyRefreshToken: null, + }; +} + +function parseMainSession(value: unknown): MainSession | null | undefined { + if (value === null) return null; + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const session = value as Record; + if ( + typeof session.accessToken !== 'string' + || !session.accessToken.trim() + || typeof session.tokenType !== 'string' + || (session.expiresAt !== null && typeof session.expiresAt !== 'number') + || typeof session.lastActiveAt !== 'number' + || typeof session.canRefresh !== 'boolean' + ) { + return undefined; + } + return { + accessToken: session.accessToken, + tokenType: session.tokenType, + expiresAt: session.expiresAt as number | null, + lastActiveAt: session.lastActiveAt, + canRefresh: session.canRefresh, + }; +} + async function syncMainSession(session: { accessToken: string | null; - refreshToken: string | null; + legacyRefreshToken: string | null; tokenType: string | null; expiresAt: number | null; -}): Promise { - if (!session.accessToken) return false; + lastActiveAt: number | null; +}): Promise { try { - const response = await hostApiFetch('/api/auth/session/sync', { + const response = await hostApiFetch('/api/auth/session/sync', { method: 'POST', body: JSON.stringify({ accessToken: session.accessToken, - refreshToken: session.refreshToken, + refreshToken: session.legacyRefreshToken, tokenType: session.tokenType, expiresAt: session.expiresAt, + lastActiveAt: session.lastActiveAt, }), }); - return response.success; - } catch { - return false; + if (!response.success) return { kind: 'unavailable' }; + const parsed = parseMainSession(response.session ?? null); + return parsed === undefined + ? { kind: 'unavailable' } + : { kind: 'success', session: parsed }; + } catch (error) { + return { kind: isTerminalAuthError(error) ? 'terminal' : 'unavailable' }; } } @@ -152,134 +233,326 @@ export const useAuthStore = create()( init: async () => { const state = get(); if (state.loading && !state.initialized) return; + const operationEpoch = authSessionEpoch; + const nowMs = Date.now(); const authBaseChanged = Boolean( state.authBase && trimTrailingSlash(state.authBase) !== trimTrailingSlash(DEFAULT_AUTH_BASE), ); - const expired = Boolean( - state.accessToken - && state.expiresAt - && state.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS, - ); - set({ - initialized: false, - loading: false, - error: null, - authBase: DEFAULT_AUTH_BASE, - clientId: DEFAULT_CLIENT_ID, - ...(expired || authBaseChanged ? getClearedSession() : {}), - }); + const restoredLastActiveAt = state.accessToken + ? (state.lastActiveAt ?? nowMs) + : null; - if (expired || authBaseChanged || !state.accessToken) { - set({ initialized: true }); - return; - } - - set({ loading: true }); - const synchronized = await syncMainSession({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, - tokenType: state.tokenType, - expiresAt: state.expiresAt, - }); - if (!synchronized) { + if (authBaseChanged) { + advanceAuthSessionEpoch(); set({ initialized: true, loading: false, - error: '登录状态恢复失败,请重新登录。', + error: null, + authBase: DEFAULT_AUTH_BASE, + clientId: DEFAULT_CLIENT_ID, + ...getClearedSession(), + }); + try { + await hostApiFetch('/api/auth/session/clear', { method: 'POST' }); + } catch { + // Main rejects sessions from a different configured gateway when restoring its store. + } + return; + } + + set({ + initialized: false, + loading: true, + error: null, + authBase: DEFAULT_AUTH_BASE, + clientId: DEFAULT_CLIENT_ID, + lastActiveAt: restoredLastActiveAt, + canRefresh: state.canRefresh || Boolean(state.legacyRefreshToken), + }); + + const synchronized = await syncMainSession({ + accessToken: state.accessToken, + legacyRefreshToken: state.legacyRefreshToken, + tokenType: state.tokenType, + expiresAt: state.expiresAt, + lastActiveAt: restoredLastActiveAt, + }); + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + + if (synchronized.kind === 'terminal') { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: '登录已过期,请重新授权。', ...getClearedSession(), }); return; } - set({ initialized: true, loading: false }); + if (synchronized.kind === 'unavailable') { + set({ initialized: true, loading: false }); + return; + } + if (!synchronized.session) { + advanceAuthSessionEpoch(); + set({ initialized: true, loading: false, error: null, ...getClearedSession() }); + return; + } + + set({ + initialized: false, + loading: true, + error: null, + ...sessionFieldsFromMain(synchronized.session), + }); + const session = synchronized.session; + const needsRefresh = Boolean( + session.expiresAt + && session.expiresAt <= nowMs + TOKEN_EXPIRY_SKEW_MS, + ); + if (needsRefresh) { + if (!session.canRefresh) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: '登录已过期,请重新授权。', + ...getClearedSession(), + }); + return; + } + await get().refreshSession({ forceRefresh: false }); + return; + } + + set({ initialized: true, loading: false, error: null }); }, loginWithBrowser: async () => { - const authBase = trimTrailingSlash(DEFAULT_AUTH_BASE); - const clientId = DEFAULT_CLIENT_ID; - + advanceAuthSessionEpoch(); + const operationEpoch = authSessionEpoch; set({ loading: true, error: null }); try { const response = await hostApiFetch('/api/auth/browser/start', { method: 'POST', }); - - if (!response.success || !response.token) { + const session = parseMainSession(response.session); + if (!response.success || !response.token || !session) { throw new Error(response.error || 'Browser authorization failed'); } + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; - const session = createSessionFromToken(response.token); set({ initialized: true, loading: false, error: null, - authBase, - clientId, - ...session, + authBase: trimTrailingSlash(DEFAULT_AUTH_BASE), + clientId: DEFAULT_CLIENT_ID, + ...sessionFieldsFromMain(session), + user: createUserFromToken(response.token), }); - if (!await syncMainSession(session)) { - throw new Error('Failed to synchronize the signed-in session'); - } } catch (error) { + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; const message = error instanceof Error ? error.message : String(error); + advanceAuthSessionEpoch(); set({ loading: false, error: message, ...getClearedSession() }); throw new Error(message, { cause: error }); } }, - refreshSession: async () => { - const { refreshToken } = get(); - if (!refreshToken) return null; + refreshSession: async (options = {}) => { + const state = get(); + if (!state.canRefresh) return null; + const operationEpoch = authSessionEpoch; + + const nowMs = Date.now(); + if (isSessionIdle(state.lastActiveAt, nowMs)) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: IDLE_LOGIN_MESSAGE, + ...getClearedSession(), + }); + return null; + } try { - const response = await hostApiFetch('/api/auth/refresh', { + const response = await hostApiFetch('/api/auth/session/refresh', { method: 'POST', - body: JSON.stringify({ refreshToken }), + body: JSON.stringify({ forceRefresh: options.forceRefresh ?? true }), }); - - if (!response.success || !response.token) { + if (!isCurrentAuthSessionEpoch(operationEpoch)) return null; + const session = parseMainSession(response.session); + if (!response.success || !session) { throw new Error(response.error || 'Refresh failed'); } - const session = createSessionFromToken(response.token, refreshToken); set({ initialized: true, loading: false, error: null, - ...session, + ...sessionFieldsFromMain(session), }); - if (!await syncMainSession(session)) { - throw new Error('Failed to synchronize the refreshed session'); - } return session.accessToken; } catch (error) { + if (!isCurrentAuthSessionEpoch(operationEpoch)) return null; const message = error instanceof Error ? error.message : String(error); + const terminal = isTerminalAuthError(error); + if (terminal) advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: message, - ...getClearedSession(), + ...(terminal ? getClearedSession() : {}), }); return null; } }, getValidAccessToken: async () => { - const { accessToken, expiresAt, refreshToken } = get(); + const { accessToken, expiresAt, canRefresh } = get(); const expired = Boolean( accessToken && expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS, ); - if (!accessToken || expired) { - return refreshToken ? await get().refreshSession() : null; + return canRefresh + ? await get().refreshSession({ forceRefresh: false }) + : null; } - return accessToken; }, + markActivity: async () => { + const state = get(); + if (!state.accessToken) return; + const operationEpoch = authSessionEpoch; + + const nowMs = Date.now(); + if (isSessionIdle(state.lastActiveAt, nowMs)) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: IDLE_LOGIN_MESSAGE, + ...getClearedSession(), + }); + try { + await hostApiFetch('/api/auth/session/clear', { method: 'POST' }); + } catch { + // Main also enforces the idle deadline before every token use. + } + return; + } + + const shouldSync = state.lastActiveAt == null + || nowMs - state.lastActiveAt >= WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS; + if (!shouldSync) return; + + set({ lastActiveAt: nowMs }); + try { + const response = await hostApiFetch('/api/auth/session/activity', { + method: 'POST', + }); + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + const session = parseMainSession(response.session); + if (response.success && session) { + set(sessionFieldsFromMain(session)); + await get().maintainSession(); + } + } catch (error) { + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + if (isTerminalAuthError(error)) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: '登录已过期,请重新授权。', + ...getClearedSession(), + }); + } + } + }, + + maintainSession: async () => { + let state = get(); + if (!state.accessToken) return; + const operationEpoch = authSessionEpoch; + + if (state.legacyRefreshToken) { + const synchronized = await syncMainSession({ + accessToken: state.accessToken, + legacyRefreshToken: state.legacyRefreshToken, + tokenType: state.tokenType, + expiresAt: state.expiresAt, + lastActiveAt: state.lastActiveAt, + }); + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + if (synchronized.kind === 'unavailable') return; + if (synchronized.kind === 'terminal' || !synchronized.session) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: '登录已过期,请重新授权。', + ...getClearedSession(), + }); + return; + } + set(sessionFieldsFromMain(synchronized.session)); + state = get(); + } + + if (isSessionIdle(state.lastActiveAt)) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: IDLE_LOGIN_MESSAGE, + ...getClearedSession(), + }); + try { + await hostApiFetch('/api/auth/session/clear', { method: 'POST' }); + } catch { + // Main still rejects the stale session on its next guarded token access. + } + return; + } + + const nearExpiry = Boolean( + state.expiresAt + && state.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS, + ); + if (!nearExpiry) return; + if (!state.canRefresh) { + advanceAuthSessionEpoch(); + set({ + initialized: true, + loading: false, + error: '登录已过期,请重新授权。', + ...getClearedSession(), + }); + return; + } + await get().refreshSession({ forceRefresh: false }); + }, + + applyMainSession: (value) => { + const session = parseMainSession(value); + if (session === undefined) return; + if (session === null) { + advanceAuthSessionEpoch(); + set({ initialized: true, loading: false, ...getClearedSession() }); + return; + } + set({ error: null, ...sessionFieldsFromMain(session) }); + }, + invalidateSession: (message = '登录已过期,请重新登录。') => { + advanceAuthSessionEpoch(); set({ initialized: true, loading: false, @@ -290,37 +563,55 @@ export const useAuthStore = create()( logout: async () => { const { accessToken } = get(); + advanceAuthSessionEpoch(); + const operationEpoch = authSessionEpoch; + set({ initialized: true, loading: false, error: null, ...getClearedSession() }); try { - if (accessToken) { - await hostApiFetch('/api/auth/logout', { - method: 'POST', - body: JSON.stringify({ accessToken }), - }); - } + await hostApiFetch('/api/auth/logout', { + method: 'POST', + body: JSON.stringify({ accessToken }), + }); } catch (error) { + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; const message = error instanceof Error ? error.message : String(error); set({ error: message }); - } finally { - set({ loading: false, ...getClearedSession() }); } }, isAuthenticated: () => { - const { accessToken, expiresAt } = get(); - if (!accessToken) return false; - if (expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) return false; + const { accessToken, expiresAt, lastActiveAt, canRefresh } = get(); + if (!accessToken || isSessionIdle(lastActiveAt)) return false; + if (expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) { + return canRefresh; + } return true; }, }), { name: 'niancode-auth', + version: 1, + migrate: (persistedState: unknown) => { + const state = persistedState && typeof persistedState === 'object' + ? persistedState as Record + : {}; + const legacyRefreshToken = asString(state.legacyRefreshToken) + ?? asString(state.refreshToken); + const { refreshToken: _refreshToken, ...rest } = state; + return { + ...rest, + canRefresh: state.canRefresh === true || Boolean(legacyRefreshToken), + legacyRefreshToken, + }; + }, partialize: (state) => ({ authBase: state.authBase, clientId: state.clientId, accessToken: state.accessToken, - refreshToken: state.refreshToken, tokenType: state.tokenType, expiresAt: state.expiresAt, + lastActiveAt: state.lastActiveAt, + canRefresh: state.canRefresh, + legacyRefreshToken: state.legacyRefreshToken, user: state.user, }), }, diff --git a/tests/setup.ts b/tests/setup.ts index a7d4bce..1e9ca14 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -25,6 +25,12 @@ vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() }, dialog: { showOpenDialog: vi.fn(), showMessageBox: vi.fn() }, shell: { openExternal: vi.fn() }, + safeStorage: { + isEncryptionAvailable: vi.fn().mockReturnValue(true), + getSelectedStorageBackend: vi.fn().mockReturnValue('gnome_libsecret'), + encryptString: vi.fn((value: string) => Buffer.from(`encrypted:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^encrypted:/, '')), + }, session: { defaultSession: { webRequest: { onBeforeSendHeaders: vi.fn() } } }, utilityProcess: {}, })); diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 3f2c9a3..f0e8e1a 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -4,9 +4,14 @@ import { shell } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleAuthRoutes } from '@electron/api/routes/auth'; import { - clearWorksSquareSession, + commitWorksSquareSession, + flushWorksSquareSessionPersistence, getWorksSquareSessionSnapshot, + initializeWorksSquareSession, + resetWorksSquareSessionForTests, + storeWorksSquareSession, } from '@electron/services/works-square-session'; +import { resetManagedWorksSquareRuntimeForTests } from '@electron/services/works-square-runtime'; const providerServiceMock = vi.hoisted(() => ({ deleteAccountApiKey: vi.fn(), @@ -52,7 +57,8 @@ function createRequest(method: string, body?: unknown): IncomingMessage { describe('auth host api routes', () => { beforeEach(() => { vi.restoreAllMocks(); - clearWorksSquareSession(); + resetWorksSquareSessionForTests(); + resetManagedWorksSquareRuntimeForTests(); providerServiceMock.deleteAccountApiKey.mockReset(); providerServiceMock.deleteAccountApiKey.mockResolvedValue(true); }); @@ -91,13 +97,19 @@ describe('auth host api routes', () => { success: true, token: { access_token: 'access-token', - refresh_token: 'refresh-token', token_type: 'Bearer', expires_in: 43200, username: 'zhangsan', user_id: '1', client_id: 'app', }, + session: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: expect.any(Number), + lastActiveAt: expect.any(Number), + canRefresh: true, + }, }); expect(fetchMock).toHaveBeenCalledOnce(); @@ -114,6 +126,41 @@ describe('auth host api routes', () => { expect(String(init.body)).not.toContain('passw0rd'); }); + it('allows explicit reauthorization to replace an unreadable persisted session', async () => { + const persistence = { + load: vi.fn().mockRejectedValue(new Error('credential cannot be decrypted')), + save: vi.fn().mockResolvedValue(undefined), + }; + await initializeWorksSquareSession({ persistence }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'replacement-access-token', + refresh_token: 'replacement-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + username: 'zhangsan', + }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/login'), + { + opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) }, + } as never, + ); + + expect(response.statusCode).toBe(200); + expect(persistence.save).toHaveBeenNthCalledWith(1, null); + expect(persistence.save).toHaveBeenLastCalledWith(expect.objectContaining({ + accessToken: 'replacement-access-token', + refreshToken: 'replacement-refresh-token', + })); + }); + it('passes through SSO credential errors without exposing secrets', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ code: 1, msg: 'Bad credentials', data: null }), { status: 401 }), @@ -182,12 +229,18 @@ describe('auth host api routes', () => { success: true, token: { access_token: 'desktop-access-token', - refresh_token: 'desktop-refresh-token', token_type: 'Bearer', expires_in: 43200, username: 'student', user_id: '42', }, + session: { + accessToken: 'desktop-access-token', + tokenType: 'Bearer', + expiresAt: expect.any(Number), + lastActiveAt: expect.any(Number), + canRefresh: true, + }, }); expect(shell.openExternal).toHaveBeenCalledWith( 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id', @@ -234,13 +287,16 @@ describe('auth host api routes', () => { expect(response.statusCode).toBe(200); expect(getWorksSquareSessionSnapshot()).toMatchObject({ accessToken: 'desktop-access-token', - refreshToken: 'desktop-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 43_200_000, + canRefresh: true, }); + expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken'); }); it('accepts renderer session sync after app restart', async () => { + vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z')); + const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; const response = createResponse(); const handled = await handleAuthRoutes( @@ -249,6 +305,7 @@ describe('auth host api routes', () => { refreshToken: 'persisted-refresh-token', tokenType: 'Bearer', expiresAt: 1_783_000_000_000, + lastActiveAt, }), response.res, new URL('http://127.0.0.1:13210/api/auth/session/sync'), @@ -257,15 +314,221 @@ describe('auth host api routes', () => { expect(handled).toBe(true); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ success: true }); + expect(response.json()).toEqual({ + success: true, + session: { + accessToken: 'persisted-access-token', + tokenType: 'Bearer', + expiresAt: 1_783_000_000_000, + lastActiveAt, + canRefresh: true, + }, + }); expect(getWorksSquareSessionSnapshot()).toEqual({ accessToken: 'persisted-access-token', - refreshToken: 'persisted-refresh-token', tokenType: 'Bearer', expiresAt: 1_783_000_000_000, + lastActiveAt, + canRefresh: true, }); }); + it('does not overwrite recoverable Main credentials when restore is temporarily unavailable', async () => { + const persistence = { + load: vi.fn().mockRejectedValue(new Error('credential store locked')), + save: vi.fn(), + }; + await initializeWorksSquareSession({ persistence }); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { + accessToken: 'renderer-access-without-refresh', + refreshToken: null, + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/session/sync'), + {} as never, + ); + + expect(response.statusCode).toBe(503); + expect(persistence.load).toHaveBeenCalledTimes(2); + expect(persistence.save).not.toHaveBeenCalled(); + expect(getWorksSquareSessionSnapshot()).toBeNull(); + }); + + it('retries Main restore before treating user activity as an expired session', async () => { + const persistedSession = { + accessToken: 'restored-access-token', + refreshToken: 'restored-refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now() - 60_000, + }; + const persistence = { + load: vi.fn() + .mockRejectedValueOnce(new Error('credential store locked')) + .mockRejectedValueOnce(new Error('credential store still locked')) + .mockResolvedValue(persistedSession), + save: vi.fn().mockResolvedValue(undefined), + }; + await initializeWorksSquareSession({ persistence }); + + const unavailableResponse = createResponse(); + await handleAuthRoutes( + createRequest('POST'), + unavailableResponse.res, + new URL('http://127.0.0.1:13210/api/auth/session/sync'), + {} as never, + ); + expect(unavailableResponse.statusCode).toBe(503); + + const activityResponse = createResponse(); + await handleAuthRoutes( + createRequest('POST'), + activityResponse.res, + new URL('http://127.0.0.1:13210/api/auth/session/activity'), + {} as never, + ); + + expect(activityResponse.statusCode).toBe(200); + expect(activityResponse.json()).toMatchObject({ + success: true, + session: { + accessToken: 'restored-access-token', + canRefresh: true, + }, + }); + expect(persistence.load).toHaveBeenCalledTimes(3); + }); + + it('does not let a stale renderer session roll back the Main-owned refresh token', async () => { + vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z')); + const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; + storeWorksSquareSession({ + accessToken: 'main-access-r1', + refreshToken: 'main-refresh-r1', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + }); + const syncResponse = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { + accessToken: 'renderer-access-r0', + refreshToken: 'renderer-refresh-r0', + tokenType: 'Bearer', + expiresAt: Date.now() + 7200_000, + lastActiveAt: Date.now(), + }), + syncResponse.res, + new URL('http://127.0.0.1:13210/api/auth/session/sync'), + {} as never, + ); + + expect(syncResponse.statusCode).toBe(200); + expect(syncResponse.json()).toEqual({ + success: true, + session: { + accessToken: 'main-access-r1', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + }, + }); + + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'main-access-r2', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const refreshResponse = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { forceRefresh: true }), + refreshResponse.res, + new URL('http://127.0.0.1:13210/api/auth/session/refresh'), + {} as never, + ); + + expect(refreshResponse.statusCode).toBe(200); + const [, refreshInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(refreshInit.body)).toBe( + 'grant_type=refresh_token&refresh_token=main-refresh-r1', + ); + expect(String(refreshInit.body)).not.toContain('renderer-refresh-r0'); + }); + + it('refreshes through the Main-owned session and returns the rotated token bundle', async () => { + vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z')); + const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'new-access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + storeWorksSquareSession({ + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() - 1, + lastActiveAt, + }); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { forceRefresh: false }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/session/refresh'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: true, + session: { + accessToken: 'new-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('updates activity only through the explicit activity route', async () => { + vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z')); + storeWorksSquareSession({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now() - 24 * 60 * 60 * 1000, + }); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST'), + response.res, + new URL('http://127.0.0.1:13210/api/auth/session/activity'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(getWorksSquareSessionSnapshot()?.lastActiveAt).toBe(Date.now()); + }); + it('stops the runtime and clears the managed Works Square key on logout', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200 }), @@ -317,4 +580,155 @@ describe('auth host api routes', () => { }); expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models'); }); + + it('does not report logout success when a remote event session cannot be closed', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { accessToken: 'access-token' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/logout'), + { + opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) }, + imageWorkspace: { + closeEventSessions: vi.fn().mockRejectedValue(new Error('remote close failed')), + }, + } as never, + ); + + expect(response.statusCode).toBe(500); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('uses the Main session token for logout instead of a stale Renderer token', async () => { + storeWorksSquareSession({ + accessToken: 'main-current-access-token', + refreshToken: 'main-current-refresh-token', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const closeEventSessions = vi.fn(async () => undefined); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { accessToken: 'renderer-stale-access-token' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/logout'), + { + opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) }, + imageWorkspace: { closeEventSessions }, + } as never, + ); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + 'https://biz.nianxx.cn/auth/token/logout', + expect.objectContaining({ + headers: { Authorization: 'Bearer main-current-access-token' }, + }), + ); + expect(closeEventSessions).toHaveBeenCalledWith({ + accessToken: 'main-current-access-token', + tolerateRemoteFailure: false, + }); + }); + + it('blocks a new login while cleanup of the previous runtime still fails', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const stop = vi.fn().mockRejectedValue(new Error('runtime stop failed')); + const context = { opencodeManager: { stop } } as never; + const logoutResponse = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { accessToken: 'old-access-token' }), + logoutResponse.res, + new URL('http://127.0.0.1:13210/api/auth/logout'), + context, + ); + expect(logoutResponse.statusCode).toBe(500); + fetchMock.mockClear(); + + const loginResponse = createResponse(); + await handleAuthRoutes( + createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }), + loginResponse.res, + new URL('http://127.0.0.1:13210/api/auth/login'), + context, + ); + + expect(loginResponse.statusCode).toBe(503); + expect(fetchMock).not.toHaveBeenCalled(); + expect(stop).toHaveBeenCalledTimes(2); + expect(getWorksSquareSessionSnapshot()).toBeNull(); + }); + + it('does not report logout success when secure storage rejects API key deletion', async () => { + providerServiceMock.deleteAccountApiKey.mockResolvedValueOnce(false); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { accessToken: 'access-token' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/logout'), + { + opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) }, + } as never, + ); + + expect(response.statusCode).toBe(500); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('still calls remote logout when clearing the persisted local session fails', async () => { + const persistence = { + load: vi.fn().mockResolvedValue(null), + save: vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('credential store locked')), + }; + await initializeWorksSquareSession({ persistence }); + await commitWorksSquareSession({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + }); + await flushWorksSquareSessionPersistence(); + + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { accessToken: 'access-token' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/logout'), + { + opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) }, + } as never, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://biz.nianxx.cn/auth/token/logout', + expect.objectContaining({ method: 'DELETE' }), + ); + expect(response.statusCode).toBe(500); + }); }); diff --git a/tests/unit/auth-store.test.ts b/tests/unit/auth-store.test.ts index e8d4973..97e1337 100644 --- a/tests/unit/auth-store.test.ts +++ b/tests/unit/auth-store.test.ts @@ -15,9 +15,11 @@ function resetAuthStore() { authBase: '', clientId: 'app', accessToken: null, - refreshToken: null, tokenType: null, expiresAt: null, + lastActiveAt: null, + canRefresh: false, + legacyRefreshToken: null, user: null, }); } @@ -41,7 +43,7 @@ describe('auth store', () => { success: true, token: { access_token: 'access-token', - refresh_token: 'refresh-token', + refresh_token: 'must-not-return-to-renderer-storage', token_type: 'Bearer', expires_in: 60, username: 'zhangsan', @@ -51,8 +53,14 @@ describe('auth store', () => { authorities: ['ROLE_USER'], client_id: 'app', }, - }) - .mockResolvedValueOnce({ success: true }); + session: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); await useAuthStore.getState().loginWithBrowser(); @@ -63,7 +71,8 @@ describe('auth store', () => { const state = useAuthStore.getState(); expect(state.isAuthenticated()).toBe(true); expect(state.accessToken).toBe('access-token'); - expect(state.refreshToken).toBe('refresh-token'); + expect(state.canRefresh).toBe(true); + expect(state.legacyRefreshToken).toBeNull(); expect(state.expiresAt).toBe(Date.now() + 60_000); expect(state.user).toEqual({ username: 'zhangsan', @@ -72,6 +81,9 @@ describe('auth store', () => { deptId: 9, authorities: ['ROLE_USER'], }); + expect(window.localStorage.getItem('niancode-auth')).not.toContain( + 'must-not-return-to-renderer-storage', + ); }); it('surfaces browser authorization failures and does not keep a partial session', async () => { @@ -91,7 +103,7 @@ describe('auth store', () => { expect(state.isAuthenticated()).toBe(false); }); - it('clears a persisted session when the configured SSO gateway changes', () => { + it('clears a persisted session when the configured SSO gateway changes', async () => { useAuthStore.setState({ initialized: false, loading: false, @@ -99,9 +111,10 @@ describe('auth store', () => { authBase: 'https://onefeel.brother7.cn/ingress/auth', clientId: 'app', accessToken: 'old-environment-token', - refreshToken: 'old-environment-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + canRefresh: true, + legacyRefreshToken: 'old-environment-refresh-token', user: { username: 'zhangsan', userId: '1', @@ -111,17 +124,29 @@ describe('auth store', () => { }, }); - useAuthStore.getState().init(); + await useAuthStore.getState().init(); const state = useAuthStore.getState(); expect(state.authBase).toBe('https://biz.nianxx.cn/auth/'); expect(state.accessToken).toBeNull(); - expect(state.refreshToken).toBeNull(); + expect(state.canRefresh).toBe(false); + expect(state.legacyRefreshToken).toBeNull(); expect(state.user).toBeNull(); expect(state.isAuthenticated()).toBe(false); }); - it('syncs a valid persisted session to Main during init', () => { + it('migrates a legacy persisted refresh token to Main during init', async () => { + const lastActiveAt = Date.now() - 60_000; + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + session: { + accessToken: 'persisted-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt, + canRefresh: true, + }, + }); useAuthStore.setState({ initialized: false, loading: false, @@ -129,9 +154,11 @@ describe('auth store', () => { authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'persisted-access-token', - refreshToken: 'persisted-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + lastActiveAt, + canRefresh: true, + legacyRefreshToken: 'persisted-refresh-token', user: { username: 'zhangsan', userId: '1', @@ -141,7 +168,7 @@ describe('auth store', () => { }, }); - useAuthStore.getState().init(); + await useAuthStore.getState().init(); expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', { method: 'POST', @@ -150,12 +177,18 @@ describe('auth store', () => { refreshToken: 'persisted-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + lastActiveAt, }), }); + expect(useAuthStore.getState().legacyRefreshToken).toBeNull(); + const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as { + state?: Record; + }; + expect(persisted.state?.legacyRefreshToken).toBeNull(); }); it('waits for Main session sync before marking persisted auth initialized', async () => { - let resolveSync!: (value: { success: boolean }) => void; + let resolveSync!: (value: unknown) => void; hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { resolveSync = resolve; })); @@ -166,9 +199,10 @@ describe('auth store', () => { authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'persisted-access-token', - refreshToken: 'persisted-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + canRefresh: true, + legacyRefreshToken: null, user: { username: 'zhangsan', userId: '1', @@ -182,13 +216,22 @@ describe('auth store', () => { expect(useAuthStore.getState().initialized).toBe(false); - resolveSync({ success: true }); + resolveSync({ + success: true, + session: { + accessToken: 'persisted-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); await initialization; expect(useAuthStore.getState().initialized).toBe(true); }); - it('clears persisted renderer auth when Main session sync fails', async () => { + it('retains persisted renderer auth when Main session sync is temporarily unavailable', async () => { hostApiFetchMock.mockRejectedValueOnce(new Error('Host API unavailable')); useAuthStore.setState({ initialized: false, @@ -197,9 +240,10 @@ describe('auth store', () => { authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'persisted-access-token', - refreshToken: 'persisted-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + canRefresh: true, + legacyRefreshToken: 'persisted-refresh-token', user: { username: 'zhangsan', userId: '1', @@ -213,25 +257,196 @@ describe('auth store', () => { expect(useAuthStore.getState()).toMatchObject({ initialized: true, + accessToken: 'persisted-access-token', + canRefresh: true, + legacyRefreshToken: 'persisted-refresh-token', + user: { username: 'zhangsan' }, + error: null, + }); + const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as { + state?: Record; + }; + expect(persisted.state?.legacyRefreshToken).toBe('persisted-refresh-token'); + }); + + it('does not let a delayed init revive a session after logout', async () => { + let resolveSync!: (value: unknown) => void; + hostApiFetchMock + .mockImplementationOnce(() => new Promise((resolve) => { + resolveSync = resolve; + })) + .mockResolvedValueOnce({ success: true }); + useAuthStore.setState({ + initialized: false, + authBase: 'https://biz.nianxx.cn/auth/', + accessToken: 'persisted-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + legacyRefreshToken: 'persisted-refresh-token', + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + const initialization = useAuthStore.getState().init(); + await useAuthStore.getState().logout(); + resolveSync({ + success: true, + session: { + accessToken: 'late-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); + await initialization; + + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + loading: false, accessToken: null, - refreshToken: null, + canRefresh: false, + legacyRefreshToken: null, user: null, - error: '登录状态恢复失败,请重新登录。', }); }); - it('syncs a refreshed session to Main', async () => { - hostApiFetchMock - .mockResolvedValueOnce({ - success: true, - token: { - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'Bearer', - expires_in: 120, - }, - }) - .mockResolvedValueOnce({ success: true }); + it('does not let delayed browser login revive a Main-terminal session', async () => { + let resolveLogin!: (value: unknown) => void; + hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveLogin = resolve; + })); + + const login = useAuthStore.getState().loginWithBrowser(); + useAuthStore.getState().applyMainSession(null); + resolveLogin({ + success: true, + token: { access_token: 'late-access-token', username: 'zhangsan' }, + session: { + accessToken: 'late-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); + await login; + + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + loading: false, + accessToken: null, + canRefresh: false, + user: null, + }); + }); + + it('does not let a delayed refresh revive an invalidated session', async () => { + let resolveRefresh!: (value: unknown) => void; + hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + useAuthStore.setState({ + initialized: true, + accessToken: 'old-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1, + lastActiveAt: Date.now(), + canRefresh: true, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + const refresh = useAuthStore.getState().refreshSession(); + useAuthStore.getState().invalidateSession('登录已失效'); + resolveRefresh({ + success: true, + session: { + accessToken: 'late-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); + + await expect(refresh).resolves.toBeNull(); + expect(useAuthStore.getState()).toMatchObject({ + accessToken: null, + canRefresh: false, + user: null, + error: '登录已失效', + }); + }); + + it('does not let delayed activity revive an invalidated session', async () => { + let resolveActivity!: (value: unknown) => void; + hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveActivity = resolve; + })); + useAuthStore.setState({ + initialized: true, + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now() - 60_000, + canRefresh: true, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + const activity = useAuthStore.getState().markActivity(); + useAuthStore.getState().invalidateSession('登录已失效'); + resolveActivity({ + success: true, + session: { + accessToken: 'late-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); + await activity; + + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState()).toMatchObject({ + accessToken: null, + canRefresh: false, + user: null, + error: '登录已失效', + }); + }); + + it('refreshes the Main-managed session and preserves the user profile', async () => { + const lastActiveAt = Date.now() - 60_000; + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + session: { + accessToken: 'new-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 120_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); useAuthStore.setState({ initialized: true, loading: false, @@ -239,9 +454,11 @@ describe('auth store', () => { authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'old-access-token', - refreshToken: 'old-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 1_000, + lastActiveAt, + canRefresh: true, + legacyRefreshToken: null, user: { username: 'zhangsan', userId: '1', @@ -253,19 +470,217 @@ describe('auth store', () => { await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token'); - expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/refresh', { + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/refresh', { method: 'POST', - body: JSON.stringify({ refreshToken: 'old-refresh-token' }), + body: JSON.stringify({ forceRefresh: true }), }); - expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/session/sync', { + expect(useAuthStore.getState().user?.username).toBe('zhangsan'); + }); + + it('restores an expired access token when Main reports it can refresh', async () => { + const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; + hostApiFetchMock + .mockResolvedValueOnce({ + success: true, + session: { + accessToken: 'expired-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1, + lastActiveAt, + canRefresh: true, + }, + }) + .mockResolvedValueOnce({ + success: true, + session: { + accessToken: 'renewed-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + }, + }); + useAuthStore.setState({ + initialized: false, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'expired-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1, + lastActiveAt, + canRefresh: true, + legacyRefreshToken: null, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + expect(useAuthStore.getState().isAuthenticated()).toBe(true); + await useAuthStore.getState().init(); + + expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/session/sync', { method: 'POST', body: JSON.stringify({ - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', + accessToken: 'expired-access-token', + refreshToken: null, tokenType: 'Bearer', - expiresAt: Date.now() + 120_000, + expiresAt: Date.now() - 1, + lastActiveAt, }), }); + expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/session/refresh', { + method: 'POST', + body: JSON.stringify({ forceRefresh: false }), + }); + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + accessToken: 'renewed-access-token', + canRefresh: true, + legacyRefreshToken: null, + lastActiveAt, + }); + }); + + it('clears a session when Main rejects it after seven inactive days', async () => { + hostApiFetchMock.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { + details: { status: 401 }, + })); + useAuthStore.setState({ + initialized: false, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now() - 7 * 24 * 60 * 60 * 1000, + canRefresh: true, + legacyRefreshToken: null, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + await useAuthStore.getState().init(); + + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', expect.any(Object)); + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + accessToken: null, + canRefresh: false, + legacyRefreshToken: null, + lastActiveAt: null, + user: null, + }); + }); + + it('persists refresh capability without persisting a new refresh token', async () => { + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + session: { + accessToken: 'new-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }); + useAuthStore.setState({ + initialized: true, + accessToken: 'old-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1, + lastActiveAt: Date.now(), + canRefresh: true, + legacyRefreshToken: null, + }); + + await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token'); + + const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as { + state?: Record; + }; + expect(persisted.state).not.toHaveProperty('refreshToken'); + expect(persisted.state).toMatchObject({ canRefresh: true, legacyRefreshToken: null }); + }); + + it('does not advance activity while maintaining the session in the background', async () => { + const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + session: { + accessToken: 'background-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + }, + }); + useAuthStore.setState({ + initialized: true, + accessToken: 'old-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 1_000, + lastActiveAt, + canRefresh: true, + legacyRefreshToken: null, + }); + + await useAuthStore.getState().maintainSession(); + + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/refresh', { + method: 'POST', + body: JSON.stringify({ forceRefresh: false }), + }); + expect(useAuthStore.getState().lastActiveAt).toBe(lastActiveAt); + }); + + it('persists Main-owned refresh capability received outside a Renderer refresh', () => { + const lastActiveAt = Date.now() - 60_000; + useAuthStore.setState({ + initialized: true, + accessToken: 'old-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt, + canRefresh: true, + legacyRefreshToken: null, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + useAuthStore.getState().applyMainSession({ + accessToken: 'main-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + }); + + const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as { + state?: Record; + }; + expect(useAuthStore.getState().user?.username).toBe('zhangsan'); + expect(useAuthStore.getState().lastActiveAt).toBe(lastActiveAt); + expect(persisted.state).not.toHaveProperty('refreshToken'); + expect(persisted.state).toMatchObject({ canRefresh: true, legacyRefreshToken: null }); }); it('clears stale renderer auth when refresh fails', async () => { @@ -277,9 +692,10 @@ describe('auth store', () => { authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'expired-access-token', - refreshToken: 'invalid-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() - 1, + canRefresh: true, + legacyRefreshToken: null, user: { username: 'zhangsan', userId: '1', @@ -293,7 +709,8 @@ describe('auth store', () => { expect(useAuthStore.getState()).toMatchObject({ accessToken: null, - refreshToken: null, + canRefresh: false, + legacyRefreshToken: null, user: null, }); }); @@ -304,9 +721,10 @@ describe('auth store', () => { authBase: 'https://gateway.example.com/auth', clientId: 'app', accessToken: 'access-token', - refreshToken: 'refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + canRefresh: true, + legacyRefreshToken: null, user: { username: 'zhangsan', userId: '1', diff --git a/tests/unit/login-page.test.tsx b/tests/unit/login-page.test.tsx index db40a68..b190b55 100644 --- a/tests/unit/login-page.test.tsx +++ b/tests/unit/login-page.test.tsx @@ -23,9 +23,11 @@ function resetAuthStore() { authBase: '', clientId: 'app', accessToken: null, - refreshToken: null, tokenType: null, expiresAt: null, + lastActiveAt: null, + canRefresh: false, + legacyRefreshToken: null, user: null, }); } @@ -75,9 +77,11 @@ describe('Login page', () => { authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'access-token', - refreshToken: 'refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + legacyRefreshToken: null, user: { username: 'brother7', userId: '1', @@ -102,9 +106,11 @@ describe('Login page', () => { act(() => { useAuthStore.setState({ accessToken: null, - refreshToken: null, tokenType: null, expiresAt: null, + lastActiveAt: null, + canRefresh: false, + legacyRefreshToken: null, user: null, }); }); @@ -112,6 +118,74 @@ describe('Login page', () => { expect(await screen.findByRole('button', { name: 'Continue in browser' })).toBeInTheDocument(); }); + it('does not record synthetic DOM events as user activity', async () => { + useSettingsStore.setState({ setupComplete: true }); + const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + legacyRefreshToken: null, + user: { + username: 'brother7', + userId: '1', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + hostApiFetchMock.mockImplementation(async (path: string) => { + if (path === '/api/auth/session/activity') { + return { + success: true, + session: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }; + } + if (path === '/api/auth/session/sync') { + return { + success: true, + session: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600_000, + lastActiveAt, + canRefresh: true, + }, + }; + } + return { success: true }; + }); + + render( + + + , + ); + + await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/auth/session/sync', + expect.any(Object), + )); + expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/session/activity')).toBe(false); + + fireEvent.keyDown(window, { key: 'a' }); + + expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/session/activity')).toBe(false); + }); + it('opens only the local image workspace anonymously when the explicit development mode is active', async () => { useSettingsStore.setState({ setupComplete: true }); window.electron.imageWorkspaceLocalDevelopment = true; @@ -251,15 +325,20 @@ describe('Login page', () => { success: true, token: { access_token: 'access-token', - refresh_token: 'refresh-token', token_type: 'Bearer', expires_in: 43200, username: 'zhangsan', user_id: '1', }, + session: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 43_200_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, }; } - if (path === '/api/auth/session/sync') return { success: true }; if (path === '/api/provider-accounts/import-user-model-config') { return { success: true, @@ -313,17 +392,20 @@ describe('Login page', () => { success: true, token: { access_token: 'fresh-access-token', - refresh_token: 'refresh-token', token_type: 'Bearer', expires_in: 43200, username: 'zhangsan', user_id: '1', }, + session: { + accessToken: 'fresh-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 43_200_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, }; } - if (path === '/api/auth/session/sync') { - return { success: true }; - } if (path === '/api/provider-accounts/import-user-model-config') { return { success: true, @@ -373,14 +455,19 @@ describe('Login page', () => { success: true, token: { access_token: 'fresh-access-token', - refresh_token: 'refresh-token', token_type: 'Bearer', expires_in: 43200, username: 'zhangsan', user_id: '1', }, + session: { + accessToken: 'fresh-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 43_200_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, }) - .mockResolvedValueOnce({ success: true }) .mockRejectedValueOnce(new Error('sync failed')) .mockResolvedValueOnce({ success: true }); diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index b65f706..67a5b0f 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -1175,7 +1175,7 @@ describe('Works Square AI design adapter', () => { ]); }); - it('keeps the persisted Session key when a close result is uncertain', async () => { + it('rotates the persisted Session key even when a close result is uncertain', async () => { const rotate = vi.fn().mockResolvedValue('design-stream-next'); const fetchMock = vi.fn() .mockResolvedValueOnce(jsonResponse({ session_id: 'session-one', status: 'active' }, 201)) @@ -1204,7 +1204,44 @@ describe('Works Square AI design adapter', () => { await expect(adapter.closeEventSessions()).rejects.toThrow( 'Failed to close 1 AI design Agent Session(s)', ); - expect(rotate).not.toHaveBeenCalled(); + expect(rotate).toHaveBeenCalledWith('workspace-one'); + }); + + it('uses the captured terminal token and tolerates remote close failure after rotating', async () => { + const rotate = vi.fn().mockResolvedValue('design-stream-next'); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ session_id: 'session-one', status: 'active' }, 201)) + .mockResolvedValueOnce(jsonResponse({ + stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one', + })) + .mockResolvedValueOnce(jsonResponse({ + detail: { code: 'token_expired', message: 'expired' }, + }, 401)); + const { webSocketFactory } = scriptedSockets([{ closeCode: 1000 }]); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + eventSessionClientIdStore: { + getOrCreate: vi.fn().mockResolvedValue('design-stream-current'), + rotate, + }, + }); + + const stream = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' }); + for await (const _event of stream.events) { + // Empty stream. + } + + await expect(adapter.closeEventSessions({ + accessToken: 'captured-terminal-access-token', + tolerateRemoteFailure: true, + })).resolves.toBeUndefined(); + const deleteCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE'); + expect(deleteCall?.[1]?.headers).toMatchObject({ + Authorization: 'Bearer captured-terminal-access-token', + }); + expect(rotate).toHaveBeenCalledWith('workspace-one'); }); it('reuses a stable Session idempotency key after an unclean application restart', async () => { diff --git a/tests/unit/works-square-session-persistence.test.ts b/tests/unit/works-square-session-persistence.test.ts new file mode 100644 index 0000000..ee362e7 --- /dev/null +++ b/tests/unit/works-square-session-persistence.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fakeStore = vi.hoisted(() => ({ + options: null as Record | null, + values: new Map(), + constructorError: null as Error | null, +})); + +vi.mock('electron-store', () => ({ + default: class FakeElectronStore { + constructor(options: Record) { + fakeStore.options = options; + if (fakeStore.constructorError && options.clearInvalidConfig !== true) { + throw fakeStore.constructorError; + } + } + + get(key: string) { + return fakeStore.values.get(key); + } + + set(key: string, value: unknown) { + fakeStore.values.set(key, value); + } + + delete(key: string) { + fakeStore.values.delete(key); + } + }, +})); + +describe('Works Square encrypted session persistence', () => { + beforeEach(() => { + vi.resetModules(); + fakeStore.options = null; + fakeStore.values.clear(); + fakeStore.constructorError = null; + }); + + it('stores one encrypted Main-only record and restores it', async () => { + const firstModule = await import('@electron/services/works-square-session'); + await firstModule.initializeWorksSquareSession(); + firstModule.storeWorksSquareSession({ + accessToken: 'plain-access-token', + refreshToken: 'plain-refresh-token', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + }); + await firstModule.flushWorksSquareSessionPersistence(); + + expect(fakeStore.options).toMatchObject({ + name: 'works-square-session', + configFileMode: 0o600, + }); + const serializedStore = JSON.stringify(Object.fromEntries(fakeStore.values)); + expect(serializedStore).not.toContain('plain-access-token'); + expect(serializedStore).not.toContain('plain-refresh-token'); + + vi.resetModules(); + const restoredModule = await import('@electron/services/works-square-session'); + await expect(restoredModule.initializeWorksSquareSession()).resolves.toMatchObject({ + accessToken: 'plain-access-token', + canRefresh: true, + }); + }); + + it('preserves a temporarily unreadable record until explicit reauthorization discards it', async () => { + const record = { + version: 1, + authBase: 'https://biz.nianxx.cn/auth/', + ciphertext: Buffer.from('encrypted:not-json', 'utf8').toString('base64'), + }; + fakeStore.values.set('record', record); + + const sessionModule = await import('@electron/services/works-square-session'); + await expect(sessionModule.initializeWorksSquareSession()).resolves.toBeNull(); + + expect(fakeStore.values.get('record')).toEqual(record); + expect(sessionModule.getWorksSquareSessionRestoreStatus()).toBe('unavailable'); + + await expect(sessionModule.discardUnrestorableWorksSquareSession()).resolves.toBe(true); + expect(fakeStore.values.has('record')).toBe(false); + expect(sessionModule.getWorksSquareSessionRestoreStatus()).toBe('ready'); + }); + + it('discards malformed store config without aborting Main startup', async () => { + fakeStore.constructorError = new SyntaxError('Unexpected token in JSON'); + + const sessionModule = await import('@electron/services/works-square-session'); + await expect(sessionModule.initializeWorksSquareSession()).resolves.toBeNull(); + + expect(fakeStore.options).toMatchObject({ + name: 'works-square-session', + configFileMode: 0o600, + clearInvalidConfig: true, + }); + expect(sessionModule.getWorksSquareSessionRestoreStatus()).toBe('ready'); + expect(sessionModule.consumeWorksSquareStartupRuntimeCleanupRequired()).toBe(true); + }); +}); diff --git a/tests/unit/works-square-session.test.ts b/tests/unit/works-square-session.test.ts index ca40257..82f914c 100644 --- a/tests/unit/works-square-session.test.ts +++ b/tests/unit/works-square-session.test.ts @@ -1,21 +1,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - clearWorksSquareSession, + consumeWorksSquareStartupRuntimeCleanupRequired, + flushWorksSquareSessionPersistence, getValidWorksSquareAccessToken, getWorksSquareSessionSnapshot, + initializeWorksSquareSession, + markWorksSquareSessionActive, + resetWorksSquareSessionForTests, storeWorksSquareSession, + subscribeWorksSquareSession, + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS, + type WorksSquareSessionInput, + type WorksSquareSessionPersistence, } from '@electron/services/works-square-session'; +const DAY_MS = 24 * 60 * 60 * 1000; + describe('works-square-session service', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z')); - clearWorksSquareSession(); + resetWorksSquareSessionForTests(); }); afterEach(() => { vi.useRealTimers(); - clearWorksSquareSession(); + resetWorksSquareSessionForTests(); }); it('returns the cached access token while it is outside the refresh skew', async () => { @@ -29,9 +39,9 @@ describe('works-square-session service', () => { await expect(getValidWorksSquareAccessToken()).resolves.toBe('access-token'); expect(getWorksSquareSessionSnapshot()).toMatchObject({ accessToken: 'access-token', - refreshToken: 'refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 120_000, + canRefresh: true, }); }); @@ -68,9 +78,9 @@ describe('works-square-session service', () => { expect(String(body)).toBe('grant_type=refresh_token&refresh_token=old-refresh-token'); expect(getWorksSquareSessionSnapshot()).toMatchObject({ accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', tokenType: 'Bearer', expiresAt: Date.now() + 43_200_000, + canRefresh: true, }); }); @@ -88,4 +98,308 @@ describe('works-square-session service', () => { await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBeNull(); expect(getWorksSquareSessionSnapshot()).toBeNull(); }); + + it('keeps the refresh token after a temporary OAuth failure', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ msg: 'temporarily unavailable' }), { status: 503 }), + ); + storeWorksSquareSession({ + accessToken: 'old-access-token', + refreshToken: 'refresh-token-for-retry', + expiresAt: Date.now() + 10_000, + }); + + await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBeNull(); + expect(getWorksSquareSessionSnapshot()).toMatchObject({ + accessToken: 'old-access-token', + canRefresh: true, + }); + }); + + it('keeps renewing when the user is active every day', async () => { + const startedAt = Date.now(); + let refreshIndex = 0; + const fetchImpl = vi.fn().mockImplementation(async () => { + refreshIndex += 1; + return new Response(JSON.stringify({ + access_token: `access-token-${refreshIndex}`, + refresh_token: `refresh-token-${refreshIndex}`, + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }); + }); + storeWorksSquareSession({ + accessToken: 'initial-access-token', + refreshToken: 'initial-refresh-token', + tokenType: 'Bearer', + expiresAt: startedAt + 3600_000, + lastActiveAt: startedAt, + }); + + for (let day = 1; day <= 8; day += 1) { + const nowMs = startedAt + day * DAY_MS; + vi.setSystemTime(nowMs); + await expect(markWorksSquareSessionActive(nowMs)).resolves.toMatchObject({ + lastActiveAt: nowMs, + }); + await expect(getValidWorksSquareAccessToken({ + fetchImpl, + nowMs, + })).resolves.toBe(`access-token-${day}`); + expect(getWorksSquareSessionSnapshot()?.lastActiveAt).toBe(nowMs); + } + + expect(fetchImpl).toHaveBeenCalledTimes(8); + expect(getWorksSquareSessionSnapshot()?.canRefresh).toBe(true); + }); + + it('requires authorization after seven days without user activity', async () => { + const lastActiveAt = Date.now(); + const fetchImpl = vi.fn(); + storeWorksSquareSession({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: lastActiveAt + 3600_000, + lastActiveAt, + }); + + const nowMs = lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS; + vi.setSystemTime(nowMs); + + await expect(getValidWorksSquareAccessToken({ + fetchImpl, + nowMs, + })).resolves.toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(getWorksSquareSessionSnapshot()).toBeNull(); + }); + + it('marks an idle session clear as terminal for Main runtime cleanup', async () => { + const listener = vi.fn(); + subscribeWorksSquareSession(listener); + const lastActiveAt = Date.now(); + storeWorksSquareSession({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: lastActiveAt + 3600_000, + lastActiveAt, + }); + listener.mockClear(); + + await getValidWorksSquareAccessToken({ + nowMs: lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS, + }); + + expect(listener).toHaveBeenCalledWith(null, 'terminal', expect.objectContaining({ + accessToken: 'access-token', + })); + }); + + it('requests one startup runtime cleanup when a persisted session is idle', async () => { + const persistence: WorksSquareSessionPersistence = { + load: vi.fn().mockResolvedValue({ + accessToken: 'stale-access-token', + refreshToken: 'stale-refresh-token', + expiresAt: Date.now() - 1, + lastActiveAt: Date.now() - WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS, + }), + save: vi.fn().mockResolvedValue(undefined), + }; + + await initializeWorksSquareSession({ persistence }); + + expect(consumeWorksSquareStartupRuntimeCleanupRequired()).toBe(true); + expect(consumeWorksSquareStartupRuntimeCleanupRequired()).toBe(false); + }); + + it('does not write an unnecessary clear when no persisted Main session exists', async () => { + const persistence: WorksSquareSessionPersistence = { + load: vi.fn().mockResolvedValue(null), + save: vi.fn().mockRejectedValue(new Error('credential store locked')), + }; + + await expect(initializeWorksSquareSession({ persistence })).resolves.toBeNull(); + + expect(persistence.save).not.toHaveBeenCalled(); + }); + + it('does not count a background refresh as user activity', async () => { + const lastActiveAt = Date.now() - DAY_MS; + const fetchImpl = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }), + ); + storeWorksSquareSession({ + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() + 10_000, + lastActiveAt, + }); + + await expect(getValidWorksSquareAccessToken({ fetchImpl })).resolves.toBe('new-access-token'); + + expect(getWorksSquareSessionSnapshot()).toMatchObject({ + canRefresh: true, + lastActiveAt, + }); + }); + + it('coalesces concurrent refreshes into one OAuth request', async () => { + let resolveRefresh!: (response: Response) => void; + const fetchImpl = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + storeWorksSquareSession({ + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() + 10_000, + }); + + const first = getValidWorksSquareAccessToken({ fetchImpl }); + const second = getValidWorksSquareAccessToken({ fetchImpl }); + expect(fetchImpl).toHaveBeenCalledOnce(); + + resolveRefresh(new Response(JSON.stringify({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 })); + + await expect(Promise.all([first, second])).resolves.toEqual([ + 'new-access-token', + 'new-access-token', + ]); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('still allows refresh one millisecond before the seven-day boundary', async () => { + const lastActiveAt = Date.now(); + const nowMs = lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS - 1; + const fetchImpl = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'renewed-access-token', + refresh_token: 'renewed-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }), + ); + storeWorksSquareSession({ + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: lastActiveAt + 3600_000, + lastActiveAt, + }); + vi.setSystemTime(nowMs); + + await expect(getValidWorksSquareAccessToken({ fetchImpl, nowMs })).resolves.toBe( + 'renewed-access-token', + ); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('does not let a stale refresh failure clear a newer login', async () => { + let resolveOldRefresh!: (response: Response) => void; + const fetchImpl = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { + resolveOldRefresh = resolve; + })); + storeWorksSquareSession({ + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() + 10_000, + }); + + const oldRefresh = getValidWorksSquareAccessToken({ fetchImpl }); + storeWorksSquareSession({ + accessToken: 'new-login-access-token', + refreshToken: 'new-login-refresh-token', + expiresAt: Date.now() + 3600_000, + }); + resolveOldRefresh(new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 401 })); + + await expect(oldRefresh).resolves.toBeNull(); + expect(getWorksSquareSessionSnapshot()).toMatchObject({ + accessToken: 'new-login-access-token', + canRefresh: true, + }); + }); + + it('does not return a newer login token to an older in-flight refresh', async () => { + let resolveOldRefresh!: (response: Response) => void; + const fetchImpl = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { + resolveOldRefresh = resolve; + })); + storeWorksSquareSession({ + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() + 10_000, + }); + + const oldRefresh = getValidWorksSquareAccessToken({ fetchImpl }); + storeWorksSquareSession({ + accessToken: 'new-login-access-token', + refreshToken: 'new-login-refresh-token', + expiresAt: Date.now() + 3600_000, + }); + resolveOldRefresh(new Response(JSON.stringify({ + access_token: 'rotated-old-access-token', + refresh_token: 'rotated-old-refresh-token', + expires_in: 3600, + }), { status: 200 })); + + await expect(oldRefresh).resolves.toBeNull(); + expect(getWorksSquareSessionSnapshot()).toMatchObject({ + accessToken: 'new-login-access-token', + canRefresh: true, + }); + }); + + it('persists a rotated refresh token before returning and restores it after restart', async () => { + let persisted: WorksSquareSessionInput | null = null; + const persistence: WorksSquareSessionPersistence = { + load: vi.fn(async () => persisted), + save: vi.fn(async (session) => { + persisted = session ? { ...session } : null; + }), + }; + await initializeWorksSquareSession({ persistence }); + storeWorksSquareSession({ + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expiresAt: Date.now() + 10_000, + }); + await flushWorksSquareSessionPersistence(); + + const firstFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'rotated-access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }), + ); + await expect(getValidWorksSquareAccessToken({ fetchImpl: firstFetch })).resolves.toBe( + 'rotated-access-token', + ); + expect(persisted).toMatchObject({ refreshToken: 'rotated-refresh-token' }); + + resetWorksSquareSessionForTests(); + await initializeWorksSquareSession({ persistence }); + const secondFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'after-restart-access-token', + refresh_token: 'after-restart-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + }), { status: 200 }), + ); + await getValidWorksSquareAccessToken({ fetchImpl: secondFetch, forceRefresh: true }); + + const body = secondFetch.mock.calls[0][1].body as URLSearchParams; + expect(String(body)).toContain('refresh_token=rotated-refresh-token'); + }); });