feat(makelore): add platform oidc login
This commit is contained in:
1 parent
87d3f03d75
commit
7236def07d
12 files changed
+528
-10
No files matched your search
@@ -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:<UUID>`, `user_id=<platform UUID>`, 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
|
||||
@@ -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。
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<Record<string, unknown>>(req), []);
|
||||
const authorization = await startPlatformAuthorization();
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
requestId: authorization.requestId,
|
||||
authorizationUrl: authorization.authorizationUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRememberedPassword(res: ServerResponse): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+60
-2
@@ -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<typeof registerIpcHandlers> | 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<void> {
|
||||
}, 'expired persisted session during startup');
|
||||
});
|
||||
}
|
||||
platformAuthReady = true;
|
||||
if (queuedPlatformAuthCallback) {
|
||||
const callback = queuedPlatformAuthCallback;
|
||||
queuedPlatformAuthCallback = null;
|
||||
completePlatformAuthCallback(callback);
|
||||
}
|
||||
if (!isE2EMode) {
|
||||
projectProgressSync = createProjectProgressSync(codingProjectStore);
|
||||
const activateProgrammingServices = (): void => {
|
||||
|
||||
@@ -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<string, PendingAuthorization>();
|
||||
let metadataPromise: Promise<PlatformAuthMetadata> | 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<PlatformAuthMetadata> {
|
||||
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<string, unknown>;
|
||||
return {
|
||||
authorization_endpoint: absoluteHttpUrl(payload.authorization_endpoint, 'authorization_endpoint'),
|
||||
token_endpoint: absoluteHttpUrl(payload.token_endpoint, 'token_endpoint'),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMetadata(): Promise<PlatformAuthMetadata> {
|
||||
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<WorksSquareTokenPayload> {
|
||||
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;
|
||||
+8
-1
@@ -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;
|
||||
}, []);
|
||||
|
||||
@@ -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() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!agreed || busy}
|
||||
onClick={() => void handlePlatformLogin()}
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
平台账号登录
|
||||
</Button>
|
||||
|
||||
<label className="flex items-start gap-2 text-xs leading-5 text-muted-foreground">
|
||||
<input type="checkbox" className="mt-1" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
|
||||
<span>
|
||||
|
||||
@@ -24,9 +24,19 @@ describe('NianCode app deep links', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the platform OIDC redirect without inventing a request id', () => {
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?code=oauth-code&state=oauth-state')).toEqual({
|
||||
type: 'desktop-auth-callback',
|
||||
url: 'niancode://auth/callback?code=oauth-code&state=oauth-state',
|
||||
code: 'oauth-code',
|
||||
state: 'oauth-state',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects links that do not target the desktop auth callback', () => {
|
||||
expect(parseNianCodeDeepLinkUrl('https://square.nianxx.cn/#desktop-auth?request_id=1')).toBeNull();
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback')).toBeNull();
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?code=oauth-code')).toBeNull();
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?request_id=')).toBeNull();
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?request_id=1&access_token=secret')).toBeNull();
|
||||
expect(parseNianCodeDeepLinkUrl('niancode://settings')).toBeNull();
|
||||
|
||||
@@ -21,10 +21,16 @@ const providerServiceMock = vi.hoisted(() => ({
|
||||
deleteAccountApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const platformAuthMock = vi.hoisted(() => ({
|
||||
startPlatformAuthorization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/services/providers/provider-service', () => ({
|
||||
getProviderService: () => providerServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/services/platform-auth', () => platformAuthMock);
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
@@ -66,6 +72,31 @@ describe('auth host api routes', () => {
|
||||
resetRememberedPasswordForTests();
|
||||
providerServiceMock.deleteAccountApiKey.mockReset();
|
||||
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
|
||||
platformAuthMock.startPlatformAuthorization.mockReset();
|
||||
platformAuthMock.startPlatformAuthorization.mockResolvedValue({
|
||||
requestId: 'request-1',
|
||||
authorizationUrl: 'https://square.example.test/authorize?state=state-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('starts platform OIDC in Main and returns only the browser authorization URL', async () => {
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleAuthRoutes(
|
||||
createRequest('POST', {}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/platform/start'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
requestId: 'request-1',
|
||||
authorizationUrl: 'https://square.example.test/authorize?state=state-1',
|
||||
});
|
||||
expect(platformAuthMock.startPlatformAuthorization).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('projects a safe current-user identity and module access without exposing secrets', async () => {
|
||||
|
||||
@@ -8,11 +8,16 @@ import { useProviderStore } from '@/stores/providers';
|
||||
import { codingRecoveryAccount } from '@/lib/coding-login-recovery';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
const invokeIpcMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||
}));
|
||||
|
||||
const loginWithPassword = vi.fn();
|
||||
const loginWithMobile = vi.fn();
|
||||
const logout = vi.fn();
|
||||
@@ -128,6 +133,7 @@ describe('Login page', () => {
|
||||
useProviderStore.setState({ importUserModelConfig });
|
||||
importUserModelConfig.mockResolvedValue(undefined);
|
||||
logout.mockResolvedValue(undefined);
|
||||
invokeIpcMock.mockResolvedValue(undefined);
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/auth/public-config') {
|
||||
return {
|
||||
@@ -153,7 +159,7 @@ describe('Login page', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows native Chinese tabs with password login as the default and no browser authorization surface', async () => {
|
||||
it('shows native Chinese tabs with password login as the default and a platform login action', async () => {
|
||||
renderLogin();
|
||||
|
||||
await screen.findByRole('link', { name: '用户协议' });
|
||||
@@ -168,9 +174,36 @@ describe('Login page', () => {
|
||||
loginButton.compareDocumentPosition(rememberPasswordCheckbox)
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(screen.getByRole('button', { name: '平台账号登录' })).toBeDisabled();
|
||||
expect(screen.queryByText(/浏览器|微信|注册/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the platform authorization URL through Main after agreement', async () => {
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/auth/public-config') return { success: true, links: {} };
|
||||
if (path === '/api/auth/remembered-password') return { success: true, available: false };
|
||||
if (path === '/api/auth/platform/start') {
|
||||
return { success: true, authorizationUrl: 'https://square.example.test/authorize?state=state-1' };
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
renderLogin();
|
||||
|
||||
const agreement = await screen.findByRole('checkbox', { name: /我已阅读并同意/ });
|
||||
fireEvent.click(agreement);
|
||||
fireEvent.click(screen.getByRole('button', { name: '平台账号登录' }));
|
||||
|
||||
await waitFor(() => expect(invokeIpcMock).toHaveBeenCalledWith(
|
||||
'shell:openExternal',
|
||||
'https://square.example.test/authorize?state=state-1',
|
||||
));
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/platform/start', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
it('restores OS-protected password credentials and keeps remember password selected', async () => {
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/auth/public-config') return { success: true, links: {} };
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
completePlatformAuthorization,
|
||||
resetPlatformAuthorizationForTests,
|
||||
startPlatformAuthorization,
|
||||
} from '@electron/services/platform-auth';
|
||||
|
||||
describe('platform OIDC authorization', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetPlatformAuthorizationForTests();
|
||||
vi.stubEnv('PLATFORM_AUTH_ISSUER', 'https://square.example.test');
|
||||
vi.stubEnv('PLATFORM_AUTH_CLIENT_ID', 'makelore');
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
it('uses discovery, state, nonce, and S256 PKCE for the public desktop client', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
authorization_endpoint: 'https://square.example.test/api/auth/oauth/authorize',
|
||||
token_endpoint: 'https://square.example.test/api/auth/oauth/token',
|
||||
}), { status: 200 }));
|
||||
|
||||
const started = await startPlatformAuthorization();
|
||||
const authorizationUrl = new URL(started.authorizationUrl);
|
||||
expect(authorizationUrl.searchParams.get('client_id')).toBe('makelore');
|
||||
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('niancode://auth/callback');
|
||||
expect(authorizationUrl.searchParams.get('response_type')).toBe('code');
|
||||
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(authorizationUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(authorizationUrl.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(authorizationUrl.searchParams.get('nonce')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(authorizationUrl.searchParams.has('request_id')).toBe(false);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}), { status: 200 }));
|
||||
const payload = await completePlatformAuthorization(
|
||||
null,
|
||||
'authorization-code',
|
||||
authorizationUrl.searchParams.get('state')!,
|
||||
);
|
||||
|
||||
expect(payload).toMatchObject({ access_token: 'access-token', refresh_token: 'refresh-token' });
|
||||
const [, init] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(init.method).toBe('POST');
|
||||
const body = new URLSearchParams(String(init.body));
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code')).toBe('authorization-code');
|
||||
expect(body.get('client_id')).toBe('makelore');
|
||||
expect(body.get('redirect_uri')).toBe('niancode://auth/callback');
|
||||
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{86}$/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('consumes a callback state once', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
authorization_endpoint: 'https://square.example.test/api/auth/oauth/authorize',
|
||||
token_endpoint: 'https://square.example.test/api/auth/oauth/token',
|
||||
}), { status: 200 }));
|
||||
const started = await startPlatformAuthorization();
|
||||
const state = new URL(started.authorizationUrl).searchParams.get('state')!;
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'access-token' }), { status: 200 }));
|
||||
|
||||
await completePlatformAuthorization(null, 'authorization-code', state);
|
||||
await expect(completePlatformAuthorization(null, 'authorization-code', state))
|
||||
.rejects.toThrow('OIDC authorization request expired');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user