From 404e70261c4b19dc36729ae669a6be5a9ae95e1e Mon Sep 17 00:00:00 2001 From: Kingsword Date: Thu, 15 Oct 2026 12:50:07 +0800 Subject: [PATCH] fix(auth): share OAuth sessions between Desktop and CLI --- docs/CONFIGURATION.md | 12 +- docs/CONFIGURATION.zh-CN.md | 9 +- docs/DEVELOPMENT.md | 10 ++ scripts/check-runtime.ts | 2 + scripts/runtime-shared-oauth-patches.ts | 160 +++++++++++++++++ scripts/sync-runtime.ts | 6 + src/runtime-config-bridge.ts | 1 + src/shared-oauth.ts | 63 +++++++ test/runtime/shared-oauth.test.ts | 224 ++++++++++++++++++++++++ test/shared-oauth.test.ts | 65 +++++++ 10 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 scripts/runtime-shared-oauth-patches.ts create mode 100644 src/shared-oauth.ts create mode 100644 test/runtime/shared-oauth.test.ts create mode 100644 test/shared-oauth.test.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f3b115b..055d4e0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -318,10 +318,18 @@ Plain `zcode login` recognizes a configured native default and reports its configuration path. Configuration presence is checked locally; credential validation and decryption belong to the runtime. +Desktop and the CLI share OAuth login when they use the same data directory and +credential secret. Signing in on either side lets the other use that account; +the CLI reads the active shared profile without requiring a second CLI login. +Explicitly configured manual API keys retain their own account selection. +Z.AI credentials use Desktop's business-token format. Older CLI OAuth credentials +are converted after successful validation on their first request; failed +validation or a concurrent login never overwrites the newer shared session. + For macOS OAuth, the CLI temporarily registers a callback receiver, checks `state`, restores the previous `zcode://` handler and sends the callback through -stdin. The runtime exchanges the token, stores encrypted credentials, resolves -the Coding Plan API key and saves the native default model. The TUI then rereads +stdin. The runtime resolves business and project access before publishing +encrypted login credentials and saving the native default model. The TUI then rereads provider configuration. BigModel uses the runtime's localhost callback, checks `state`, and sends the matching callback URI and state to `https://zcode.z.ai/api/v1/oauth/token`. Both the BigModel access token and ZCode diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md index 825a4a9..5a23422 100644 --- a/docs/CONFIGURATION.zh-CN.md +++ b/docs/CONFIGURATION.zh-CN.md @@ -273,9 +273,14 @@ Provider 文件中的 `config.defaultModelSelection` 决定新会话使用的模 普通的 `zcode login` 会识别已经配置的原生默认模型并提示配置路径。 本地仅检查配置是否存在,凭证校验与解密由 runtime 负责。 +桌面端与 CLI 使用相同的数据目录和凭证加密密钥时,共享 OAuth 登录状态。 +任意一端登录后,另一端即可使用该账号;CLI 直接读取共享的当前账号资料,无需再次登录。 +显式配置的手动 API Key 仍使用其独立的账号选择。Z.AI 凭证统一采用桌面端的业务令牌格式; +旧版 CLI 保存的 OAuth 令牌会在首次请求验证成功后转换,验证失败或并发登录不会覆盖更新的共享会话。 + macOS OAuth 会临时注册回调接收器、校验 `state`,恢复原来的 `zcode://` 处理程序,并通过 -stdin 把回调交给 runtime。Runtime 交换令牌、保存加密凭证、解析 Coding Plan API Key, -再保存原生默认模型;TUI 随后重新读取 provider 配置。BigModel 使用 runtime 的 localhost +stdin 把回调交给 runtime。Runtime 先取得业务和项目访问令牌,再保存加密登录凭证与 +原生默认模型;TUI 随后重新读取 provider 配置。BigModel 使用 runtime 的 localhost 回调,校验 `state` 后,将匹配的回调地址和 state 提交到 `https://zcode.z.ai/api/v1/oauth/token`。BigModel access token 和 ZCode JWT 均通过原生 凭证存储加密保存。回调须能访问运行 CLI 的机器;SSH 环境需要将回调 URL 中的端口转发到该机器。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8dfdb3c..3ed0d0a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -119,6 +119,16 @@ render `1M` instead of `200K`. ## OAuth login +The required `shared-oauth` runtime patch keeps Desktop and standalone CLI +credentials compatible. `oauth:zai:access_token` contains the business token +returned by `/api/auth/z/login`, while the OAuth input remains transient. +Standalone account identities come from the active shared profile; explicit +manual API-key connections keep their native identity. Legacy CLI OAuth tokens +are exchanged only after a business HTTP 401, then replaced under the native +credential lock if the stored value still matches. Run +`bun test test/shared-oauth.test.ts test/runtime/shared-oauth.test.ts` after +syncing to cover both login transports, Desktop session reuse and mutation races. + For the OAuth path, run the launcher directly with the login subcommand: ```bash diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index e00b78a..9a555fd 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -23,6 +23,7 @@ import { hasRuntimeHttpNoContentGuard, hasRuntimeNetworkRetryGuard, hasRuntimeResolvedContextWindow, + hasRuntimeSharedOAuth, hasRuntimeSqliteBusyTimeout, hasRuntimeSqliteWriteRecovery, hasRuntimeStreamEofFinishGuard, @@ -103,6 +104,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || !hasRuntimeBrowserUrlOpening(runtimeSource) || !hasRuntimeConfigurationDiagnostics(runtimeSource) || !hasRuntimeResolvedContextWindow(runtimeSource) + || !hasRuntimeSharedOAuth(runtimeSource) || !hasRuntimeCliSettingsFile(runtimeSource) || !runtimeSource.includes(".readRuntimeProjection=async()=>{let $zRuntimeProjectionBridge=await ") || !runtimeSource.includes('"plugin://"') diff --git a/scripts/runtime-shared-oauth-patches.ts b/scripts/runtime-shared-oauth-patches.ts new file mode 100644 index 0000000..21019c4 --- /dev/null +++ b/scripts/runtime-shared-oauth-patches.ts @@ -0,0 +1,160 @@ +import { parseExpressionAt, type FunctionExpression } from "acorn"; + +const identifier = "[A-Za-z_$][\\w$]*"; +const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; +const marker = "zaiTokenKind"; + +function incompatible(detail: string): never { + throw new Error(`ZCode runtime is incompatible with shared OAuth (${detail}).`); +} + +function unique(values: T[], name: string): T { + if (values.length !== 1) incompatible(`${name} missing or ambiguous`); + return values[0]!; +} + +function escape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function symbol(runtime: string, label: string): string { + return unique([...runtime.matchAll(new RegExp(`${identifier}\\((${identifier}),"${label}"\\)`, "gu"))], label)[1]!; +} + +function namedFunction(runtime: string, label: string): FunctionExpression { + const name = symbol(runtime, label); + const start = unique([...runtime.matchAll(new RegExp(`(?:async )?function ${escape(name)}\\(`, "gu"))], label).index!; + const fn = parseExpressionAt(runtime, start, { ecmaVersion: "latest" }); + if (fn.type !== "FunctionExpression") incompatible(`${label} declaration changed`); + return fn; +} + +export function hasRuntimeSharedOAuth(runtime: string): boolean { + if (!runtime.includes(marker)) return false; + try { + const body = (name: string) => { const fn = namedFunction(runtime, name); return runtime.slice(fn.start, fn.end); }; + const login = body("loginZCodeCli"), resolver = body("createCodingPlanApiKeyResolver"); + const headers = body("createStandaloneProviderRuntimeHeadersPort"); + return headers.includes(`${marker}:"business"`) && headers.includes(".saveIfValue(") + && headers.includes(".sharedOAuthCredentialReader(") + && body("readStandaloneAccountProviderConfigSnapshot").includes(".sharedOAuthCredentialReader(") + && body("createSharedZCodeCredentialStore").includes("async saveIfValue(") + && resolver.includes(`${marker}==="business"`) && resolver.includes("zaiBusinessToken:") + && /\.business\.token\|\|new Headers\(/u.test(resolver) + && [...login.matchAll(/\.saveZaiLoginCredentials\(\{accessToken:require\(require\("node:path"\)\.join\(__dirname,"cli-config.cjs"\)\)\.zaiBusinessAccessToken\(/gu)].length === 2 + && login.includes(".zaiBusinessAccessToken($zApiKey)") + && login.indexOf("let $zApiKey=") < login.indexOf("$zStore.saveZaiLoginCredentials("); + } catch { return false; } +} + +/** Keep the Desktop credential contract across both login transports and standalone requests. */ +export function patchRuntimeSharedOAuth(runtime: string): string { + if (hasRuntimeSharedOAuth(runtime)) return runtime; + if (runtime.includes(marker)) incompatible("partial patch"); + const edits: { start: number; end: number; value: string }[] = []; + const replace = (fn: FunctionExpression, pattern: RegExp, change: (match: RegExpMatchArray) => string, name: string) => { + const source = runtime.slice(fn.start, fn.end); + const match = unique([...source.matchAll(pattern)], name); + edits.push({ start: fn.start + match.index!, end: fn.start + match.index! + match[0].length, value: change(match) }); + }; + + const resolver = namedFunction(runtime, "createCodingPlanApiKeyResolver"); + const resolverSource = runtime.slice(resolver.start, resolver.end); + // 3.15.3 sends bare business tokens to metadata endpoints and Bearer tokens + // to project issuance. Both forms must participate in native 401 recovery. + replace(resolver, /new Headers\(([A-Za-z_$][\w$]*)\.headers\)\.get\("Authorization"\)===`Bearer \$\{([A-Za-z_$][\w$]*)\.business\.token\}`/gu, + match => `(new Headers(${match[1]}.headers).get("Authorization")===${match[2]}.business.token||${match[0]})`, "business credential rejection"); + const business = unique([...resolverSource.matchAll(new RegExp(`(${identifier})=(${identifier})\\.family==="zai"\\?await (${identifier})\\.resolve\\(`, "gu"))], "business exchange"); + const attempt = unique([...resolverSource.matchAll(new RegExp(`for\\(let (${identifier})=0;`, "gu"))], "business retry")[1]!; + // Shared credentials are business tokens. A definitive business HTTP 401 can + // also mean a pre-fix CLI OAuth token; the native bounded retry exchanges it. + replace(resolver, new RegExp(escape(business[0]), "gu"), match => match[0].replace('==="zai"?', + `==="zai"?${business[2]}.${marker}==="business"&&${attempt}===0?{token:${business[2]}.accessToken.trim()}:`), "business exchange"); + const project = unique([...resolverSource.matchAll(new RegExp(`let (${identifier})=await ${identifier}\\.resolve\\(\\{origin:`, "gu"))], "project material")[1]!; + replace(resolver, new RegExp(`return ${escape(project)}(?=\\})`, "gu"), () => `return ${business[1]}?{...${project},zaiBusinessToken:${business[1]}.token}:${project}`, "resolved business token"); + + const snapshot = namedFunction(runtime, "readStandaloneAccountProviderConfigSnapshot"); + const snapshotSource = runtime.slice(snapshot.start, snapshot.end); + const providers = unique([...snapshotSource.matchAll(new RegExp(`(${identifier})=${identifier}\\.providers,(${identifier})=\\1\\.map\\(`, "gu"))], "captured provider catalog"); + replace(snapshot, new RegExp(`await (${identifier})\\.loadMany\\(${escape(providers[2]!)}\\)`, "gu"), + match => `await ${helper}.sharedOAuthCredentialReader(${match[1]},async()=>${providers[1]}).loadMany(${providers[2]})`, "shared account identities"); + const headers = namedFunction(runtime, "createStandaloneProviderRuntimeHeadersPort"); + const [headerStore, headerEnv] = headers.params; + if (headerStore?.type !== "Identifier" || headerEnv?.type !== "Identifier") incompatible("request auth parameters changed"); + // Request auth already carries the registry's captured provider/family pair. + // Reopening the configured catalog here would bypass native fallback recovery. + edits.push({ start: headers.body.start + 1, end: headers.body.start + 1, + value: `let $zSharedProviders=new Map;${headerStore.name}=${helper}.sharedOAuthCredentialReader(${headerStore.name},async()=>[...$zSharedProviders.values()]);` }); + replace(headers, /async refreshBeforeModelRequest\(([A-Za-z_$][\w$]*)\)\{/gu, + match => `${match[0]}$zSharedProviders.set(${match[1]}.providerId.trim(),{providerId:${match[1]}.providerId.trim(),family:${match[1]}.accountAccess?.accountType});`, "request provider identity"); + replace(headers, /\.resolveMaterial\(\{family:([A-Za-z_$][\w$]*),accessToken:([A-Za-z_$][\w$]*),/gu, + match => `${match[0]}${marker}:"business",`, "shared token request"); + + const credentials = namedFunction(runtime, "createSharedZCodeCredentialStore"); + const returned = credentials.body.body.find(node => node.type === "ReturnStatement"); + if (returned?.type !== "ReturnStatement" || returned.argument?.type !== "ObjectExpression") incompatible("credential store changed"); + if (returned.argument.properties.some(node => node.type === "Property" && node.key.type === "Identifier" && node.key.name === "saveIfValue")) incompatible("credential comparison already exists"); + const compare = unique(returned.argument.properties.filter(node => node.type === "Property" + && node.key.type === "Identifier" && node.key.name === "deleteIfValue"), "credential comparison"); + if (compare.type !== "Property" || compare.value.type !== "FunctionExpression") incompatible("credential comparison changed"); + const [key, expected] = compare.value.params; + const validation = compare.value.body.body[0]; + if (key?.type !== "Identifier" || expected?.type !== "Identifier" || validation?.type !== "VariableDeclaration") incompatible("credential validation changed"); + const validator = validation.declarations[1]?.init; + if (validator?.type !== "CallExpression" || validator.callee.type !== "Identifier") incompatible("credential value validation changed"); + const compareSource = runtime.slice(compare.start, compare.end); + const cipher = unique([...compareSource.matchAll(new RegExp(`(${identifier})\\.decrypt\\(`, "gu"))], "credential cipher")[1]!; + const deletion = unique([...compareSource.matchAll(new RegExp(`delete (${identifier})\\[(${identifier})\\]`, "gu"))], "conditional credential mutation"); + const conditionalSave = compareSource.replace(`deleteIfValue(${key.name},${expected.name})`, `saveIfValue(${key.name},${expected.name},$zValue)`) + .replace(deletion[0], `${deletion[1]}[${deletion[2]}]=${cipher}.encrypt(${validator.callee.name}($zValue))`); + edits.push({ start: compare.end, end: compare.end, value: `,${conditionalSave}` }); + + const headerSource = runtime.slice(headers.start, headers.end); + const request = unique([...headerSource.matchAll(new RegExp(`let (${identifier})=await (${identifier})\\.resolveMaterial\\(\\{family:(${identifier}),accessToken:(${identifier}),accountIdentity:(${identifier}),[^{}]+\\},\\{signal:(${identifier})\\.abortSignal\\}\\);`, "gu"))], "shared request material"); + const scope = unique([...headerSource.matchAll(new RegExp(`(${identifier})=\\(0,${identifier}\\.createHash\\)\\("sha256"\\)\\.update\\(JSON\\.stringify\\(\\[${identifier},${identifier},${identifier},${escape(request[4]!)}\\]\\)\\)\\.digest\\("hex"\\)`, "gu"))], "request account scope"); + const snapshotEntry = unique([...headerSource.matchAll(new RegExp(`(${identifier})\\.set\\((${identifier}),${escape(request[4]!)}\\)`, "gu"))], "credential snapshot"); + const store = headers.params[0]; + if (store?.type !== "Identifier") incompatible("request credential store changed"); + const [materialName, resolverName, familyName, tokenName, , inputName] = request.slice(1); + const businessToken = `${materialName}.zaiBusinessToken`, oauthKey = snapshotEntry[2]; + const migration = `if(${familyName}==="zai"&&${businessToken}&&${businessToken}!==${tokenName}){${inputName}.abortSignal?.throwIfAborted();` + + `if(!await ${store.name}.saveIfValue(${oauthKey},${tokenName},${businessToken})&&await ${store.name}.load(${oauthKey})!==${businessToken})throw ${resolverName}.clear(),new Error("project_token_scope_invalidated");` + + `${tokenName}=${businessToken};${scope[0]};${snapshotEntry[1]}.set(${oauthKey},${tokenName});}`; + const migrationStart = headers.start + request.index! + request[0].length; + edits.push({ start: migrationStart, end: migrationStart, value: migration }); + + const login = namedFunction(runtime, "loginZCodeCli"); + const materialResolver = symbol(runtime, "resolveCodingPlanMaterial"); + const source = runtime.slice(login.start, login.end); + const material = unique([...source.matchAll(new RegExp(`(${identifier})=await ${identifier}\\(${escape(materialResolver)}\\(\\{accessToken:(${identifier})\\.accessToken,`, "gu"))], "polling login material"); + replace(login, new RegExp(`\\.saveZaiLoginCredentials\\(\\{accessToken:${escape(material[2]!)}\\.accessToken,`, "gu"), + () => `.saveZaiLoginCredentials({accessToken:${helper}.zaiBusinessAccessToken(${material[1]}),`, "polling credential publication"); + + // The macOS bridge originally published the raw token before resolving model access. + // Move resolution ahead of publication so a failed exchange leaves the shared login intact. + const bridge = login.body.body.find(node => node.type === "IfStatement" + && runtime.slice(node.test.start, node.test.end).includes("ZCODE_CLI_OAUTH_CALLBACK_STDIN")); + if (bridge?.type !== "IfStatement" || bridge.consequent.type !== "BlockStatement") incompatible("macOS login bridge missing"); + const statements = bridge.consequent.body; + const publication = unique(statements.filter(node => node.type === "TryStatement" + && runtime.slice(node.block.start, node.block.end).includes("$zStore.saveZaiLoginCredentials")), "macOS credential publication"); + const resolution = unique(statements.filter(node => node.type === "VariableDeclaration" + && node.declarations.some(declaration => declaration.id.type === "Identifier" && declaration.id.name === "$zApiKey")), "macOS login material"); + const resolutionSource = runtime.slice(resolution.start, resolution.end); + if (!resolutionSource.includes(`await ${materialResolver}(`) || publication.end > resolution.start) incompatible("macOS publication order changed"); + const loginOptions = login.params[0]; + if (loginOptions?.type !== "AssignmentPattern" || loginOptions.left.type !== "Identifier") incompatible("login options changed"); + edits.push({ start: publication.start, end: publication.start, + value: `${resolutionSource}${loginOptions.left.name}.abortSignal?.throwIfAborted();` }); + edits.push({ start: resolution.start, end: resolution.end, value: "" }); + const accessToken = unique([...runtime.slice(publication.start, publication.end).matchAll(/accessToken:\$zAccessToken,/gu)], "macOS shared token"); + edits.push({ start: publication.start + accessToken.index!, end: publication.start + accessToken.index! + accessToken[0].length, + value: `accessToken:${helper}.zaiBusinessAccessToken($zApiKey),` }); + + // Reject unexpected overlaps before touching the bundle. + const sorted = edits.sort((a, b) => b.start - a.start); + for (let i = 1; i < sorted.length; i++) if (sorted[i]!.end > sorted[i - 1]!.start) incompatible("overlapping edits"); + for (const edit of sorted) runtime = runtime.slice(0, edit.start) + edit.value + runtime.slice(edit.end); + if (!hasRuntimeSharedOAuth(runtime)) incompatible("postcondition failed"); + return runtime; +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 5656616..b4be1f1 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -15,6 +15,8 @@ import { import { parseReleaseVersion, syncedReleaseVersion } from "./release-version.ts"; import { markRuntimeModified } from "./runtime-attribution.ts"; import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts"; +import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts"; +export { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts"; export { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts"; import { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "./runtime-browser-patches.ts"; export { hasRuntimeBrowserUrlOpening, patchRuntimeBrowserUrlOpening } from "./runtime-browser-patches.ts"; @@ -1870,6 +1872,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ apply: patchRuntimeBigmodelSessionReuse, verify: (runtime) => runtime.includes(bigmodelSessionReuseMarker) }, + { + id: "shared-oauth", requirement: "required", apply: patchRuntimeSharedOAuth, + verify: hasRuntimeSharedOAuth + }, { id: "login-model-defaults", requirement: "required", diff --git a/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index b20b576..994d43a 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -11,6 +11,7 @@ export { installSqliteWriteRecovery, pruneSqliteUsage, sqliteRecoveryStats } fro export { appServerRegistryOptions } from "./app-server-auth.ts"; export { exchangeBigmodelOAuthCode } from "./bigmodel-oauth.ts"; export { browserOpenCommand } from "./browser-open.ts"; +export { sharedOAuthCredentialReader, zaiBusinessAccessToken } from "./shared-oauth.ts"; export { inspectRuntimeConfiguration, writeConfigurationDoctor } from "./config-diagnostics.ts"; function record(value: unknown): Record | undefined { diff --git a/src/shared-oauth.ts b/src/shared-oauth.ts new file mode 100644 index 0000000..d55ad13 --- /dev/null +++ b/src/shared-oauth.ts @@ -0,0 +1,63 @@ +interface CredentialReader { + loadMany(keys: string[]): Promise>; +} + +interface AccountProvider { + providerId: string; + family: string; +} + +const activeProviderKey = "oauth:active_provider"; +const identityKey = (provider: string) => `account-provider:${provider}:identity`; +const sourceKey = (provider: string) => `account-provider:${provider}:auth-source`; + +function profileIdentity(value: string | null | undefined): string | null { + if (!value) return null; + try { + const profile = JSON.parse(value); + const id = profile?.user_id ?? profile?.id ?? profile?.rawProfile?.user_id; + return typeof id === "string" && id.trim() ? id.trim() : null; + } catch { + return null; + } +} + +/** Desktop owns oauth:*; standalone account markers must not shadow that session. */ +export function sharedOAuthCredentialReader( + store: T, + providers: () => Promise, +): Omit & CredentialReader & { load(key: string): Promise } { + const loadMany = async (keys: string[]): Promise> => { + const identityKeys = keys.filter(key => key.startsWith("account-provider:") && key.endsWith(":identity")); + if (!identityKeys.length) return store.loadMany(keys); + const catalog = (await providers()).filter(provider => identityKeys.includes(identityKey(provider.providerId))); + if (!catalog.length) return store.loadMany(keys); + const sourceKeys = catalog.map(provider => sourceKey(provider.providerId)); + const sources = await store.loadMany(sourceKeys); + const shared = catalog.filter(provider => sources[sourceKey(provider.providerId)] !== "manual"); + if (!shared.length) return store.loadMany(keys); + + // Do not decrypt stale CLI identities for OAuth, or unrelated OAuth data for manual keys. + const overridden = new Set(shared.map(provider => identityKey(provider.providerId))); + const values = await store.loadMany([...keys.filter(key => !overridden.has(key)), activeProviderKey]); + const family = values[activeProviderKey]; + let identity: string | null = null; + if (family && shared.some(provider => provider.family === family)) { + const profileKey = `oauth:${family}:user_info`; + const current = await store.loadMany([activeProviderKey, profileKey]); + if (current[activeProviderKey] === family) identity = profileIdentity(current[profileKey]); + } + for (const provider of shared) { + values[identityKey(provider.providerId)] = provider.family === family ? identity : null; + } + return Object.fromEntries(keys.map(key => [key, values[key] ?? null])); + }; + return { ...store, loadMany, load: async key => (await loadMany([key]))[key] ?? null }; +} + +/** A successful OAuth exchange must publish Desktop's business token, never its OAuth input. */ +export function zaiBusinessAccessToken(material: { zaiBusinessToken?: string }): string { + const token = material.zaiBusinessToken?.trim(); + if (!token) throw new Error("Z.AI login did not return a business access token."); + return token; +} diff --git a/test/runtime/shared-oauth.test.ts b/test/runtime/shared-oauth.test.ts new file mode 100644 index 0000000..12b6ead --- /dev/null +++ b/test/runtime/shared-oauth.test.ts @@ -0,0 +1,224 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; +import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "../../scripts/runtime-shared-oauth-patches.ts"; + +const root = join(import.meta.dir, "../.."); + +test("the shared OAuth patch is idempotent and rejects partially patched credentials", async () => { + const source = await readFile(join(root, "vendor/zcode.cjs"), "utf8"); + expect(hasRuntimeSharedOAuth(source)).toBe(true); + expect(patchRuntimeSharedOAuth(source)).toBe(source); + for (const partial of [source.replace('zaiTokenKind:"business"', 'zaiTokenKind:"oauth"'), + source.replace("async saveIfValue(", "async removedIfValue(")]) { + expect(hasRuntimeSharedOAuth(partial)).toBe(false); + expect(() => patchRuntimeSharedOAuth(partial)).toThrow("partial patch"); + } +}, 15_000); + +/** Run native login, encryption, account projection and request signing; only HTTP is simulated. */ +async function probe(body: string, options: { callback?: boolean } = {}) { + const directory = await mkdtemp(join(tmpdir(), "zcode-shared-oauth-")); + try { + const file = join(root, "vendor/zcode.cjs"); + const source = await readFile(file, "utf8"); + const names = ["loginZCodeCli", "createSharedZCodeCredentialStore", "createCodingPlanApiKeyResolver", + "readStandaloneAccountProviderConfigSnapshot", "createStandaloneProviderRuntimeHeadersPort"]; + const symbols = names.map(name => { + const match = new RegExp(`[A-Za-z_$][\\w$]*\\(([A-Za-z_$][\\w$]*),"${name}"\\)`, "u").exec(source); + if (!match) throw new Error(`Missing native function: ${name}`); + const init = [...source.slice(0, match.index).matchAll(/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu)].at(-1)?.[1]; + if (!init) throw new Error(`Missing native initializer: ${name}`); + return { symbol: match[1], init }; + }); + const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source); + if (!main) throw new Error("Missing native entry point"); + const injected = symbols.map(value => `${value.init}();`).join("") + + `await(async(${names.join(",")})=>{${httpFixture}try{${body};console.log("SHARED_OAUTH_OK")}catch(error){console.error("Fixture requests:",JSON.stringify(calls));throw error}})(${symbols.map(value => value.symbol).join(",")}).catch(error=>{console.error(error.stack);process.exitCode=1});return;`; + const script = `const fs=require("node:fs"),Module=require("node:module"),path=require("node:path"); + const file=${JSON.stringify(file)},runtime=new Module(file,module); + runtime.filename=file;runtime.paths=Module._nodeModulePaths(path.dirname(file)); + runtime._compile(fs.readFileSync(file,"utf8").replace(${JSON.stringify(main[0])},${JSON.stringify(main[0] + injected)}),file);`; + const child = Bun.spawn([Bun.which("node")!, "--eval", script], { + cwd: directory, + env: { ...runtimeTestEnv(directory), ZCODE_CREDENTIAL_SECRET: "shared-oauth-test-secret", + ...options.callback ? { ZCODE_CLI_OAUTH_CALLBACK_STDIN: "1" } : {} }, + stdin: new Blob([JSON.stringify({ callbackUrl: "zcode://zai-auth/callback?code=fixture-code&state=fixture-state", state: "fixture-state" })]), + stdout: "pipe", stderr: "pipe" + }); + const timer = setTimeout(() => child.kill(), 12_000); + try { + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, stderr || stdout).toBe(0); + expect(stdout).toContain("SHARED_OAUTH_OK"); + } finally { clearTimeout(timer); } + } finally { await rm(directory, { recursive: true, force: true }); } +} + +const httpFixture = ` + const assert=require("node:assert/strict"),fs=require("node:fs/promises"); + const env={...process.env},providerId="account:zai-individual-coding-plan"; + const store=createSharedZCodeCredentialStore({env}); + const calls=[]; + let businessToken="fixture-business-token",projectToken="fixture-project-token",rejectExchange=false,rejectedStatus=401,beforeIssue; + const response=(data,status=200)=>({status,body:new TextEncoder().encode(JSON.stringify({code:status===200?0:status,data}))}); + const httpClient={request:async(request)=>{try{ + const url=new URL(request.url);calls.push(url.pathname); + if(url.pathname==="/api/v1/oauth/cli/init")return response({authorize_url:"https://example.test/login",flow_id:"flow",expires_at:Math.ceil(Date.now()/1000)+60,poll_interval_sec:1}); + if(url.pathname==="/api/v1/oauth/cli/poll/flow")return response({status:"ready",token:"fixture-zcode-jwt",user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}}); + if(url.pathname==="/api/v1/oauth/token")return response({token:"fixture-zcode-jwt",user:{user_id:"fixture-user"},zai:{access_token:"fixture-oauth-token"}}); + if(url.pathname==="/api/auth/z/login"){ + const input=JSON.parse(new TextDecoder().decode(request.body)); + if(rejectExchange||input.token!=="fixture-oauth-token")return response({},401); + return response({access_token:businessToken}); + } + const auth=new Headers(request.headers).get("authorization"); + if(auth!=="Bearer "+businessToken&&(auth!==businessToken||url.pathname.endsWith("/access_tokens")))return response({},rejectedStatus); + if(url.pathname==="/api/biz/customer/getCustomerInfo")return response({organizations:[{organizationId:"org",projects:[{projectId:"project"}]}]}); + if(url.pathname.endsWith("/api_keys"))return response([{apiKey:"key-id",name:"zcode-api-key"}]); + if(url.pathname.endsWith("/access_tokens")){ + await beforeIssue?.(); + return response({accessToken:projectToken,tokenType:"Bearer",expiresIn:3600,expiresAt:Math.ceil(Date.now()/1000)+3600}); + } + throw new Error("Unexpected fixture request: "+url.pathname); + }catch(error){console.error("Fixture HTTP error:",error);throw error}}}; + const resolver=()=>createCodingPlanApiKeyResolver({httpClient}); + const seed=async(user="desktop-user")=>store.saveMany({"oauth:active_provider":"zai","oauth:zai:access_token":businessToken,"oauth:zai:user_info":JSON.stringify({user_id:user}),zcodejwttoken:"desktop-jwt"}); + const input={providerId,accountAccess:{mode:"individual-coding-plan",accountType:"zai"}}; +`; + +for (const callback of [false, true]) test(`Z.AI ${callback ? "macOS callback" : "polling"} login publishes a token Desktop and CLI can both use`, async () => { + await probe(` + await loginZCodeCli({env,noBrowser:true,httpClient}); + assert.equal(await store.load("oauth:zai:access_token"),businessToken); + assert.equal(await store.load("zcodejwttoken"),"fixture-zcode-jwt"); + assert.deepEqual(JSON.parse(await store.load("oauth:zai:user_info")),{user_id:"fixture-user"}); + const saved=await fs.readFile(store.filePath,"utf8"); + assert.ok(saved.includes("enc:v1:")); + assert.ok(!saved.includes(businessToken)&&!saved.includes("fixture-oauth-token")); + const desktop=await httpClient.request({url:"https://api.z.ai/api/biz/customer/getCustomerInfo",headers:{Authorization:"Bearer "+await store.load("oauth:zai:access_token")}}); + assert.equal(desktop.status,200); + calls.length=0; + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + assert.equal((await headers.refreshBeforeModelRequest(input)).requestAuth.apiKey,projectToken); + assert.ok(!calls.includes("/api/auth/z/login")); + `, { callback }); +}, 15_000); + +test("a Desktop-only session authorizes CLI requests without a second login", async () => { + await probe(` + await seed(); + assert.equal(await store.load("account-provider:"+providerId+":identity"),null); + const snapshot=await readStandaloneAccountProviderConfigSnapshot(store,env); + assert.equal(snapshot.providers.entries().find(([id])=>id===providerId)[1].access.entitled,true); + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + assert.equal((await headers.refreshBeforeModelRequest(input)).requestAuth.apiKey,projectToken); + assert.ok(!calls.includes("/api/auth/z/login")); + assert.equal(await store.load("oauth:zai:access_token"),businessToken); + `); +}, 15_000); + +test("running CLI requests follow Desktop account changes and reject a late result after logout", async () => { + await probe(` + await seed(); + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + const first=await headers.refreshBeforeModelRequest(input); + businessToken="second-business-token";projectToken="second-project-token"; + await seed("second-user"); + const second=await headers.refreshBeforeModelRequest(input); + assert.equal(second.requestAuth.apiKey,projectToken); + assert.notEqual(first.requestAuth.accountScope,second.requestAuth.accountScope); + await assert.rejects(headers.refreshBeforeModelRequest({...input,expectedAccountScope:first.requestAuth.accountScope}),/project_token_scope_invalidated/); + headers.clearProjectTokens(); + beforeIssue=()=>store.delete("oauth:active_provider"); + await assert.rejects(headers.refreshBeforeModelRequest(input),/project_token_scope_invalidated/); + await assert.rejects(headers.refreshBeforeModelRequest(input),/凭据已经失效/); + assert.ok(!calls.includes("/api/auth/z/login")); + `); +}, 15_000); + +test("a rejected business exchange leaves the existing Desktop login intact", async () => { + await probe(` + await seed(); + const previous=await fs.readFile(store.filePath,"utf8"); + rejectExchange=true; + await assert.rejects(loginZCodeCli({env,noBrowser:true,httpClient}),/project_token_login_failed/); + assert.ok(await fs.readFile(store.filePath,"utf8")===previous,"failed login overwrote the shared Desktop session"); + `, { callback: true }); +}, 15_000); + +test("legacy CLI OAuth credentials migrate to Desktop's format after a confirmed exchange", async () => { + await probe(` + await seed("fixture-user"); + await store.save("oauth:zai:access_token","fixture-oauth-token"); + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + const first=await headers.refreshBeforeModelRequest(input); + assert.equal(first.requestAuth.apiKey,projectToken); + assert.equal(await store.load("oauth:zai:access_token"),businessToken); + assert.equal(calls.filter(path=>path==="/api/auth/z/login").length,1); + calls.length=0; + const second=await headers.refreshBeforeModelRequest(input); + assert.equal(second.requestAuth.accountScope,first.requestAuth.accountScope); + assert.ok(!calls.includes("/api/auth/z/login")); + `); +}, 15_000); + +test("legacy migration cannot overwrite a newer Desktop login", async () => { + await probe(` + await seed("fixture-user"); + await store.save("oauth:zai:access_token","fixture-oauth-token"); + beforeIssue=async()=>{businessToken="new-desktop-token";await seed("new-desktop-user")}; + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + await assert.rejects(headers.refreshBeforeModelRequest(input),/project_token_scope_invalidated/); + assert.equal(await store.load("oauth:zai:access_token"),"new-desktop-token"); + assert.equal(JSON.parse(await store.load("oauth:zai:user_info")).user_id,"new-desktop-user"); + `); +}, 15_000); + +test("permission failures do not trigger legacy conversion or change the shared token", async () => { + await probe(` + await seed(); + await store.save("oauth:zai:access_token","fixture-oauth-token"); + rejectedStatus=403; + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + await assert.rejects(headers.refreshBeforeModelRequest(input),/project_token_request_failed/); + assert.ok(!calls.includes("/api/auth/z/login")); + assert.equal(await store.load("oauth:zai:access_token"),"fixture-oauth-token"); + `); +}, 15_000); + +test("cancelling macOS login after material resolution preserves the shared session", async () => { + await probe(` + await seed(); + const previous=await fs.readFile(store.filePath,"utf8"),controller=new AbortController(); + await assert.rejects(loginZCodeCli({env,noBrowser:true,httpClient,abortSignal:controller.signal, + apiKeyResolver:{resolveMaterial:async()=>{controller.abort(new Error("fixture login cancelled"));return{zaiBusinessToken:businessToken}}} + }),/fixture login cancelled/); + assert.ok(await fs.readFile(store.filePath,"utf8")===previous); + `, { callback: true }); +}, 15_000); + +test("a Desktop BigModel profile also supplies the CLI account identity", async () => { + await probe(` + const id="account:bigmodel-individual-coding-plan"; + await store.saveMany({"oauth:active_provider":"bigmodel","oauth:bigmodel:access_token":businessToken, + "oauth:bigmodel:user_info":JSON.stringify({id:"desktop-bigmodel-user",username:"Fixture",displayName:"Fixture"}),zcodejwttoken:"desktop-jwt"}); + const snapshot=await readStandaloneAccountProviderConfigSnapshot(store,env); + assert.equal(snapshot.providers.entries().find(([provider])=>provider===id)[1].access.entitled,true); + assert.equal(snapshot.providers.entries().find(([provider])=>provider===providerId)[1].access.entitled,false); + const headers=createStandaloneProviderRuntimeHeadersPort(store,env,resolver()); + const auth=await headers.refreshBeforeModelRequest({providerId:id,accountAccess:{mode:"individual-coding-plan",accountType:"bigmodel"}}); + assert.equal(auth.requestAuth.apiKey,projectToken); + assert.ok(!calls.includes("/api/auth/z/login")); + `); +}, 15_000); + +test("request auth uses the resolved provider even when the original catalog path is unavailable", async () => { + await probe(` + await seed(); + const headers=createStandaloneProviderRuntimeHeadersPort(store,{...env,ZCODE_BUILTIN_PROVIDER_CONFIG_FILE:"/missing/fixture-catalog.json"},resolver()); + assert.equal((await headers.refreshBeforeModelRequest(input)).requestAuth.apiKey,projectToken); + `); +}, 15_000); diff --git a/test/shared-oauth.test.ts b/test/shared-oauth.test.ts new file mode 100644 index 0000000..26bbec4 --- /dev/null +++ b/test/shared-oauth.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { sharedOAuthCredentialReader, zaiBusinessAccessToken } from "../src/shared-oauth.ts"; + +const zai = "account:zai-individual-coding-plan", bigmodel = "account:bigmodel-individual-coding-plan"; +const identity = (id: string) => `account-provider:${id}:identity`; +const source = (id: string) => `account-provider:${id}:auth-source`; +const catalog = async () => [{ providerId: zai, family: "zai" }, { providerId: bigmodel, family: "bigmodel" }]; + +function fixture(initial: Record = {}) { + const values: Record = { ...initial }; + const reads: string[][] = []; + const store = { async loadMany(keys: string[]) { + reads.push(keys); + if (keys.some(key => values[key] === "corrupt")) throw new Error("decrypt failed"); + return Object.fromEntries(keys.map(key => [key, values[key] ?? null])); + } }; + return { values, reads, reader: sharedOAuthCredentialReader(store, catalog) }; +} + +test("Desktop's active profile supplies the standalone identity without CLI login markers", async () => { + const f = fixture({ "oauth:active_provider": "zai", "oauth:zai:user_info": JSON.stringify({ user_id: "desktop-user" }), + [identity(zai)]: "corrupt" }); + expect(await f.reader.loadMany([identity(zai), identity(bigmodel)])).toEqual({ [identity(zai)]: "desktop-user", [identity(bigmodel)]: null }); + expect(f.values[identity(zai)]).toBe("corrupt"); + f.values["oauth:zai:user_info"] = JSON.stringify({ id: "new-user", username: "User", displayName: "User" }); + expect(await f.reader.load(identity(zai))).toBe("new-user"); + f.values["oauth:zai:user_info"] = JSON.stringify({ id: "normalized-user", rawProfile: { user_id: "raw-user" } }); + expect(await f.reader.load(identity(zai))).toBe("normalized-user"); + delete f.values["oauth:active_provider"]; + expect(await f.reader.load(identity(zai))).toBeNull(); +}); + +test("an explicit manual key remains usable without decrypting unrelated OAuth credentials", async () => { + const f = fixture({ [source(zai)]: "manual", [identity(zai)]: "manual-user", "oauth:active_provider": "corrupt" }); + expect(await f.reader.load(identity(zai))).toBe("manual-user"); + expect(f.reads.flat()).not.toContain("oauth:active_provider"); +}); + +test("switching the active platform disconnects stale OAuth markers from the previous platform", async () => { + const f = fixture({ "oauth:active_provider": "bigmodel", "oauth:bigmodel:user_info": JSON.stringify({ id: "bigmodel-user" }), + [source(zai)]: "oauth", [identity(zai)]: "old-zai-user", "oauth:zai:user_info": "corrupt" }); + expect(await f.reader.loadMany([identity(zai), identity(bigmodel)])).toEqual({ [identity(zai)]: null, [identity(bigmodel)]: "bigmodel-user" }); + expect(f.reads.flat()).not.toContain("oauth:zai:user_info"); +}); + +test("a missing or malformed profile cannot reuse a stale CLI identity", async () => { + const f = fixture({ "oauth:active_provider": "zai", [identity(zai)]: "old-user" }); + for (const value of ["", "{", "{}", '{"user_id":42}', '{"user_id":" "}']) { + f.values["oauth:zai:user_info"] = value; + expect(await f.reader.load(identity(zai))).toBeNull(); + } +}); + +test("unrelated credential reads retain the native store contract", async () => { + const f = fixture({ "mcp:test": "value", "account-provider:custom:identity": "custom-user" }); + expect(await f.reader.loadMany(["mcp:test", "missing", "account-provider:custom:identity"])) + .toEqual({ "mcp:test": "value", missing: null, "account-provider:custom:identity": "custom-user" }); + expect(f.reads).toHaveLength(1); +}); + +test("login cannot publish an OAuth token or project token as a business credential", () => { + expect(zaiBusinessAccessToken({ zaiBusinessToken: " business-token " })).toBe("business-token"); + expect(() => zaiBusinessAccessToken({})).toThrow("business access token"); + expect(() => zaiBusinessAccessToken({ zaiBusinessToken: " " })).toThrow("business access token"); +});