From 7236def07d6a9a5cfde70c3239c6ac53764bcd7d Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 10 Oct 2026 17:29:33 +0800 Subject: [PATCH] feat(makelore): add platform oidc login --- .../20261010-makelore-platform-sso-a1b2.md | 53 ++++++ README.md | 4 +- electron/api/routes/auth.ts | 22 +++ electron/main/app-deep-link.ts | 24 ++- electron/main/index.ts | 62 +++++- electron/services/platform-auth.ts | 179 ++++++++++++++++++ src/App.tsx | 9 +- src/pages/Login/index.tsx | 36 ++++ tests/unit/app-deep-link.test.ts | 10 + tests/unit/auth-routes.test.ts | 31 +++ tests/unit/login-page.test.tsx | 35 +++- tests/unit/platform-auth.test.ts | 73 +++++++ 12 files changed, 528 insertions(+), 10 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261010-makelore-platform-sso-a1b2.md create mode 100644 electron/services/platform-auth.ts create mode 100644 tests/unit/platform-auth.test.ts diff --git a/.project-docs/30-worklog/tasks/20261010-makelore-platform-sso-a1b2.md b/.project-docs/30-worklog/tasks/20261010-makelore-platform-sso-a1b2.md new file mode 100644 index 00000000..ececf086 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261010-makelore-platform-sso-a1b2.md @@ -0,0 +1,53 @@ +# Task: Makelore platform SSO client integration + +## Identity + +- Task ID: 20261010-makelore-platform-sso-a1b2 +- Mode: Feature +- Branch: codex/20261010-makelore-platform-sso-a1b2-20261010-makelore-platform-sso-a1b2 +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20261010-makelore-platform-sso-a1b2 +- Base commit: 87d3f03d751b93b607e56d3667c3655f2a03a792 +- Owner: codex-client-integrations +- Status: In Progress + +## Scope + +- Replace the Makelore client’s direct one-feel password/mobile proxy as the primary sign-in path with a platform OIDC Authorization Code flow using PKCE S256. +- Keep the flow in Electron Main: generate state and verifier, open the system browser, accept only an authorization code on `niancode://auth/callback`, exchange it against the platform token endpoint, and keep refresh credentials in Main’s existing secure session store. +- Project the platform identity (`sub=platform:`, `user_id=`, issuer/audience) into the existing Renderer auth state without copying browser refresh credentials or changing LMS, payment, or server migration behavior. +- Add focused Main/deep-link/session tests and update the owning product documentation for the new login boundary. + +## Intent And Constraints + +- The parent platform contract is authoritative: issuer comes from `platform_auth_issuer`; discovery, JWKS, authorize, token, userinfo, and revoke use the standard `/api/auth/oauth/*` paths; Makelore audience defaults to `works-square-api`. +- The desktop client uses the system browser and PKCE S256. Client secrets, if the registered desktop client requires one, remain Main-owned and are read from OS-protected configuration; Renderer never receives them. +- A callback carries `code` and `state` only. Access/refresh tokens in a deep-link are rejected. State is single-use and bound to the generated verifier and redirect URI. +- Existing Main session refresh, account partitioning, logout, and Renderer capability projection remain the owners of local session behavior. +- Do not infer identity by username, email, or legacy `auth_user_id`; preserve any legacy identifiers only as opaque migration metadata. Do not deploy, migrate real data, or modify the LMS. +- Other active Makelore tasks concern marketplace, fullscreen, teacher concurrency, WeChat diagnosis, or review-only work; none owns auth routes, deep-link parsing, or session storage. Gate result: Passed. + +## Outcome + +- Added a Main-owned platform OIDC Authorization Code + PKCE flow. Main discovers the platform authorization and token endpoints, generates one-time state/nonce/verifier values, opens the system browser through the renderer-safe Host API route, accepts the registered `niancode://auth/callback` code/state redirect, cleans the previous local runtime, and persists the returned Works Square session. The callback correlates by state because the platform redirect does not append the legacy desktop `request_id`; legacy request-id-only links remain accepted for compatibility. +- Added explicit `/api/auth/platform/start` Host API routing, a platform login action on the native login page, and renderer account rehydration from `/api/auth/me` after Main commits the callback session. Access/refresh credentials remain Main-owned. +- Updated the product README and added focused tests for callback parsing, discovery/PKCE/replay, route projection, and login-page browser launch. + +## Verification + +- Planning gate passed after reading the project positioning, current state, decision index, system overview, data flow, module map, business rules, stale-items, commitments, and peer task records. +- `pnpm exec vitest run tests/unit/app-deep-link.test.ts tests/unit/platform-auth.test.ts tests/unit/auth-routes.test.ts tests/unit/login-page.test.tsx --maxWorkers=1` (65 tests passed). +- `pnpm run typecheck` passed. +- `pnpm run lint:check -- electron/main/app-deep-link.ts electron/services/platform-auth.ts electron/api/routes/auth.ts electron/main/index.ts src/App.tsx src/pages/Login/index.tsx tests/unit/app-deep-link.test.ts tests/unit/platform-auth.test.ts tests/unit/auth-routes.test.ts tests/unit/login-page.test.tsx` passed with eight pre-existing warnings outside the changed files and no errors. +- Production deployment, real-account migration, and LMS changes were not performed. + +## Follow-ups + +- The platform issuer/client configuration must be supplied in the packaged deployment (`PLATFORM_AUTH_ISSUER`, with optional `PLATFORM_AUTH_CLIENT_ID`/`PLATFORM_AUTH_SCOPE`); this local task intentionally does not deploy or register clients. + +## Promotion Candidates + +- None. The product README documents the implemented client behavior; shared architecture promotion remains owned by the parent integration task. + +## Status + +Ready for Integration diff --git a/README.md b/README.md index aeef282b..42dff735 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用平台账号 OIDC 登录,也可使用账号密码或手机号短信验证码登录。平台账号登录由 Electron Main 从 `PLATFORM_AUTH_ISSUER` 发现授权端点,使用系统浏览器完成 Authorization Code + PKCE(S256),回到 `niancode://auth/callback` 后由 Main 校验 state、交换令牌并保存会话;Renderer 不接触 client secret、refresh token 或授权回调凭据。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 @@ -111,7 +111,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Renderer 的后端调用统一经过 `src/lib/host-api.ts` 或 `src/lib/api-client.ts`;请求先经 Main-owned IPC,再由兼容 Host API 路由处理。只有真正需要 URL 的资源和流会把 loopback 地址暴露给 Renderer。 - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成;所有 stream、watcher、poller、loopback server 与子进程必须登记到模块活动和任务租约,不允许页面自行创建无托管后台任务。 -- Works Square 原生密码与短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;可选的记住密码记录使用独立的 Main-owned 加密存储,退出登录不会清除它,只有成功的未勾选密码登录才清除旧记录。未打包开发版只在内存持有会话且禁用记住密码,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除),账号密码不进入 Renderer 持久状态。 +- 平台账号 OIDC 登录与 Works Square 原生密码、短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。OIDC 使用公开 `makelore` client、精确回调 `niancode://auth/callback`、state/nonce 和 S256 PKCE;Main 在会话切换前清理旧账号的本地运行时,再持久化新的平台 access/refresh token。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;可选的记住密码记录使用独立的 Main-owned 加密存储,退出登录不会清除它,只有成功的未勾选密码登录才清除旧记录。未打包开发版只在内存持有会话且禁用记住密码,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除),账号密码不进入 Renderer 持久状态。 - AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。发布构建同时提供 Main-owned `ReleaseJob` 的 start/progress/status/cancel 契约,同一项目串行执行并支持取消;异步 Job 的扫描、依赖安装、构建和双归档均在独立 `utilityProcess` 中以流式文件处理,Main 只接收进度、摘要和契约,旧的同步提交接口继续兼容已有客户端。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 - AI 编程项目配置只以项目内 `.makelore/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。Main 不探测、读取或迁移 `.niancode` 与 `.opencode` 项目数据。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Workspace 所属的持久 Agent Session、稳定命令身份、有界 Run 查询、可恢复事件订阅与契约映射,并通过本机 Host API 的 SSE 投影同步表单、任务和资产状态。切换 Workspace 只重连对应流;注销或退出时关闭本地流,不删除服务端持久 Session。远端 Token、Provider Prompt 与存储地址不进入 Renderer。 diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index e6d549e0..b16e3817 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -27,6 +27,7 @@ import { getRememberedPasswordState, updateRememberedPassword, } from '../../services/remembered-password'; +import { startPlatformAuthorization } from '../../services/platform-auth'; type PasswordLoginInput = { username?: unknown; @@ -335,6 +336,22 @@ async function handlePasswordLogin( }); } +async function handlePlatformAuthorizationStart( + req: IncomingMessage, + res: ServerResponse, +): Promise { + // Keep the route body intentionally empty: the Main process owns the OIDC + // client configuration and all PKCE state. The renderer only receives a + // browser URL and never handles a client secret. + readExactJsonObject(await parseJsonBody>(req), []); + const authorization = await startPlatformAuthorization(); + sendJson(res, 200, { + success: true, + requestId: authorization.requestId, + authorizationUrl: authorization.authorizationUrl, + }); +} + async function handleRememberedPassword(res: ServerResponse): Promise { const state = await getRememberedPasswordState(); res.setHeader('Cache-Control', 'no-store'); @@ -791,6 +808,11 @@ export async function handleAuthRoutes( return true; } + if (url.pathname === '/api/auth/platform/start' && req.method === 'POST') { + await handlePlatformAuthorizationStart(req, res); + return true; + } + if (url.pathname === '/api/auth/remembered-password' && req.method === 'GET') { await handleRememberedPassword(res); return true; diff --git a/electron/main/app-deep-link.ts b/electron/main/app-deep-link.ts index 59204495..039e5dbd 100644 --- a/electron/main/app-deep-link.ts +++ b/electron/main/app-deep-link.ts @@ -2,8 +2,11 @@ export const NIANCODE_APP_PROTOCOL = 'niancode'; export type NianCodeDeepLink = { type: 'desktop-auth-callback'; - requestId: string; + /** Legacy desktop auth callbacks carry a request id. OIDC callbacks use state. */ + requestId?: string; url: string; + code?: string; + state?: string; } | { type: 'cloud-agent'; slug: string; url: string } | { type: 'teacher-preview'; draftRevision: number; url: string }; @@ -29,6 +32,7 @@ const SENSITIVE_QUERY_KEYS = new Set([ 'device_secret', 'token', ]); +const AUTH_CALLBACK_QUERY_KEYS = new Set(['request_id', 'code', 'state']); export function parseNianCodeDeepLinkUrl(rawUrl: string): NianCodeDeepLink | null { let url: URL; @@ -67,15 +71,27 @@ export function parseNianCodeDeepLinkUrl(rawUrl: string): NianCodeDeepLink | nul } } - const requestId = url.searchParams.get('request_id')?.trim(); - if (!requestId || requestId.length > 128) { + if ([...url.searchParams.keys()].some((key) => !AUTH_CALLBACK_QUERY_KEYS.has(key))) { return null; } + const requestId = url.searchParams.get('request_id')?.trim() || undefined; + if (requestId && requestId.length > 128) return null; + + const code = url.searchParams.get('code')?.trim() || undefined; + const state = url.searchParams.get('state')?.trim() || undefined; + if (Boolean(code) !== Boolean(state) || (code && code.length > 4096) || (state && state.length > 512)) { + return null; + } + // The platform OIDC redirect is registered as niancode://auth/callback and + // therefore returns code/state without the legacy request_id parameter. + if (!requestId && !(code && state)) return null; + return { type: 'desktop-auth-callback', - requestId, + ...(requestId ? { requestId } : {}), url: rawUrl, + ...(code ? { code, state } : {}), }; } diff --git a/electron/main/index.ts b/electron/main/index.ts index d3c58216..881215b9 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -65,6 +65,7 @@ import { createReleaseUtilityPreparer } from '../services/release-utility-proces import { createStaticArtifactSnapshot } from '../services/static-release-server'; import { consumeWorksSquareStartupRuntimeCleanupRequired, + commitWorksSquareSessionFromTokenPayload, getWorksSquareSessionRestoreStatus, getWorksSquareSessionSnapshot, initializeWorksSquareSession, @@ -73,7 +74,11 @@ import { } from '../services/works-square-session'; import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy'; import { initializeRememberedPassword } from '../services/remembered-password'; -import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime'; +import { + clearManagedWorksSquareRuntimeBestEffort, + ensureManagedWorksSquareRuntimeClean, +} from '../services/works-square-runtime'; +import { completePlatformAuthorization } from '../services/platform-auth'; import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; import type { DesignWorkspaceModule } from '../image-workspace/module'; import { @@ -207,6 +212,12 @@ let releaseJobs: ReleaseJobManager | null = null; let codingProducts: CodingProductComposition | null = null; let windowIpcBindings: ReturnType | null = null; let unsubscribeAuthSession: (() => void) | null = null; +let platformAuthReady = false; +let queuedPlatformAuthCallback: { + requestId: string | null; + code: string; + state: string; +} | null = null; const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv); @@ -365,6 +376,33 @@ function requestMainWindowFocus(reason: string): void { logger.debug(`Main window is not ready yet; deferring focus for ${reason}`); } +function completePlatformAuthCallback(callback: { + requestId: string | null; + code: string; + state: string; +}): void { + void (async () => { + // Match the password/mobile login path: clean the previous account's + // derived runtime before making the new account active. Keeping the + // authorization pending until cleanup succeeds lets the user retry after + // a transient local cleanup failure. + await ensureManagedWorksSquareRuntimeClean({ + codingProducts: codingProducts ?? undefined, + imageWorkspace: imageWorkspaceModule ?? undefined, + }); + const payload = await completePlatformAuthorization( + callback.requestId, + callback.code, + callback.state, + ); + await commitWorksSquareSessionFromTokenPayload(payload); + requestMainWindowFocus('platform OIDC callback'); + })().catch((error) => { + logger.warn('[auth] Platform OIDC callback was not completed', error); + requestMainWindowFocus('platform OIDC callback failure'); + }); +} + function handleAppDeepLinkActivation(rawUrl: string): boolean { const deepLink = parseNianCodeDeepLinkUrl(rawUrl); if (!deepLink) { @@ -378,7 +416,21 @@ function handleAppDeepLinkActivation(rawUrl: string): boolean { queueTeacherPreviewRevision(deepLink.draftRevision); mainWindow?.webContents.send('navigate', '/coding-teacher-preview?draftRevision=' + deepLink.draftRevision); } else { - logger.info(`Received Makelore app link: type=${deepLink.type}, request_id=${deepLink.requestId}`); + if (deepLink.code && deepLink.state) { + const callback = { + requestId: deepLink.requestId ?? null, + code: deepLink.code, + state: deepLink.state, + }; + if (platformAuthReady) { + completePlatformAuthCallback(callback); + } else { + queuedPlatformAuthCallback = callback; + logger.debug('[auth] Queued platform OIDC callback until session restore is ready'); + } + } else { + logger.info(`Received Makelore app link: type=${deepLink.type}, request_id=${deepLink.requestId ?? 'none'}`); + } } requestMainWindowFocus('app deep link'); return true; @@ -664,6 +716,12 @@ async function initialize(): Promise { }, 'expired persisted session during startup'); }); } + platformAuthReady = true; + if (queuedPlatformAuthCallback) { + const callback = queuedPlatformAuthCallback; + queuedPlatformAuthCallback = null; + completePlatformAuthCallback(callback); + } if (!isE2EMode) { projectProgressSync = createProjectProgressSync(codingProjectStore); const activateProgrammingServices = (): void => { diff --git a/electron/services/platform-auth.ts b/electron/services/platform-auth.ts new file mode 100644 index 00000000..35157be0 --- /dev/null +++ b/electron/services/platform-auth.ts @@ -0,0 +1,179 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; +import type { WorksSquareTokenPayload } from './works-square-session'; + +const DEFAULT_CLIENT_ID = 'makelore'; +const DEFAULT_SCOPE = 'openid profile phone offline_access'; +const REDIRECT_URI = 'niancode://auth/callback'; +const STATE_TTL_MS = 10 * 60_000; + +type PlatformAuthMetadata = { + authorization_endpoint: string; + token_endpoint: string; +}; + +type PendingAuthorization = { + state: string; + codeVerifier: string; + expiresAt: number; +}; + +const pendingAuthorizations = new Map(); +let metadataPromise: Promise | null = null; + +function configuredIssuer(): string { + const raw = (process.env.PLATFORM_AUTH_ISSUER || WORKS_SQUARE_CONFIG.apiBaseUrl).trim().replace(/\/+$/, ''); + const parsed = new URL(raw); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('PLATFORM_AUTH_ISSUER must be an absolute HTTP(S) URL without credentials or query parameters'); + } + return parsed.toString().replace(/\/$/, ''); +} + +function configuredClientId(): string { + return process.env.PLATFORM_AUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID; +} + +function configuredScope(): string { + return process.env.PLATFORM_AUTH_SCOPE?.trim() || DEFAULT_SCOPE; +} + +function absoluteHttpUrl(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) throw new Error(`OIDC discovery missing ${field}`); + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + throw new Error(`OIDC discovery returned an invalid ${field}`); + } + return parsed.toString(); +} + +async function loadMetadata(): Promise { + const issuer = configuredIssuer(); + const response = await proxyAwareFetch(`${issuer}/.well-known/openid-configuration`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`OIDC discovery failed (${response.status})`); + const payload = await response.json() as Record; + return { + authorization_endpoint: absoluteHttpUrl(payload.authorization_endpoint, 'authorization_endpoint'), + token_endpoint: absoluteHttpUrl(payload.token_endpoint, 'token_endpoint'), + }; +} + +async function getMetadata(): Promise { + if (!metadataPromise) { + metadataPromise = loadMetadata().catch((error) => { + metadataPromise = null; + throw error; + }); + } + return metadataPromise; +} + +function takePendingAuthorization( + requestId: string | null, + returnedState: string, +): PendingAuthorization | null { + if (requestId) { + const pending = pendingAuthorizations.get(requestId); + pendingAuthorizations.delete(requestId); + return pending ?? null; + } + + // The platform redirect is a fixed niancode:// URI and does not append our + // local request id. The state value is the OIDC correlation handle instead. + for (const [candidateId, pending] of pendingAuthorizations) { + if (pending.state === returnedState) { + pendingAuthorizations.delete(candidateId); + return pending; + } + } + return null; +} + +function cleanupPendingAuthorizations(now = Date.now()): void { + for (const [requestId, pending] of pendingAuthorizations) { + if (pending.expiresAt <= now) pendingAuthorizations.delete(requestId); + } +} + +function codeChallengeS256(verifier: string): string { + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +} + +function readTokenPayload(value: unknown): WorksSquareTokenPayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('OIDC token endpoint returned an invalid response'); + } + const payload = value as WorksSquareTokenPayload; + if (typeof payload.access_token !== 'string' || !payload.access_token.trim()) { + throw new Error('OIDC token endpoint did not return access_token'); + } + return payload; +} + +export async function startPlatformAuthorization(): Promise<{ + requestId: string; + authorizationUrl: string; +}> { + cleanupPendingAuthorizations(); + const metadata = await getMetadata(); + const requestId = randomUUID(); + const state = randomBytes(32).toString('base64url'); + const codeVerifier = randomBytes(64).toString('base64url'); + pendingAuthorizations.set(requestId, { + state, + codeVerifier, + expiresAt: Date.now() + STATE_TTL_MS, + }); + + const url = new URL(metadata.authorization_endpoint); + url.searchParams.set('client_id', configuredClientId()); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('redirect_uri', REDIRECT_URI); + url.searchParams.set('scope', configuredScope()); + url.searchParams.set('state', state); + url.searchParams.set('nonce', randomBytes(32).toString('base64url')); + url.searchParams.set('code_challenge', codeChallengeS256(codeVerifier)); + url.searchParams.set('code_challenge_method', 'S256'); + + return { requestId, authorizationUrl: url.toString() }; +} + +export async function completePlatformAuthorization( + requestId: string | null, + code: string, + returnedState: string, +): Promise { + cleanupPendingAuthorizations(); + const pending = takePendingAuthorization(requestId, returnedState); + if (!pending || pending.expiresAt <= Date.now()) throw new Error('OIDC authorization request expired'); + if (pending.state !== returnedState) throw new Error('OIDC authorization state mismatch'); + + const metadata = await getMetadata(); + const response = await proxyAwareFetch(metadata.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: configuredClientId(), + code_verifier: pending.codeVerifier, + }).toString(), + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`OIDC token exchange failed (${response.status}): ${detail.slice(0, 180)}`); + } + return readTokenPayload(await response.json()); +} + +export function resetPlatformAuthorizationForTests(): void { + pendingAuthorizations.clear(); + metadataPromise = null; +} + +export const PLATFORM_AUTH_REDIRECT_URI = REDIRECT_URI; diff --git a/src/App.tsx b/src/App.tsx index c6c028e7..c133a156 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -284,7 +284,14 @@ function App() { useEffect(() => { const unsubscribe = subscribeHostEvent('auth:session-changed', (session) => { - useAuthStore.getState().applyMainSession(session); + const auth = useAuthStore.getState(); + auth.applyMainSession(session); + // A platform OIDC callback is committed by Main and only carries the + // session credentials. Reload the current account so the renderer gets + // the stable username and module permissions before routing. + if (session) { + void auth.init().catch(() => undefined); + } }); return unsubscribe; }, []); diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 263db02b..f68c28ea 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { hostApiFetch } from '@/lib/host-api'; +import { invokeIpc } from '@/lib/api-client'; import { useAuthStore } from '@/stores/auth'; import { useProviderStore } from '@/stores/providers'; import logoSvg from '@/assets/logo.svg'; @@ -307,6 +308,30 @@ export function Login() { } }; + const handlePlatformLogin = async () => { + if (!agreed || busy) return; + setSubmitError(null); + setSubmitting(true); + try { + const response = await hostApiFetch<{ + success?: unknown; + authorizationUrl?: unknown; + }>('/api/auth/platform/start', { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({}), + }); + if (response.success !== true) throw new Error('平台登录暂时不可用,请稍后重试。'); + const authorizationUrl = getSafeExternalUrl(response.authorizationUrl); + if (!authorizationUrl) throw new Error('平台登录地址无效,请稍后重试。'); + await invokeIpc('shell:openExternal', authorizationUrl); + } catch (loginError) { + setSubmitError(loginError instanceof Error ? loginError.message : String(loginError)); + } finally { + setSubmitting(false); + } + }; + const handleSendCode = async () => { if ( sendingCode @@ -464,6 +489,17 @@ export function Login() { )} + +