diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index 9b778300..3bd4c30a 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -32,6 +32,53 @@ interface TokenResponse { interval?: number; } +interface KimiJwtPayload { + user_id?: unknown; + sub?: unknown; + email?: unknown; +} + +function decodeKimiJwtPayload(token: string): KimiJwtPayload | undefined { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length !== 3 || !payload) return undefined; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as KimiJwtPayload; + } catch { + return undefined; + } +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Stable multiauth identity from Kimi JWTs (`user_id` preferred; `sub` / `email` fallbacks). */ +export function identityFromKimiTokens(accessToken: string, refreshToken?: string): { + accountId?: string; + email?: string; +} { + const fromAccess = identityFromPayload(decodeKimiJwtPayload(accessToken)); + const fromRefresh = refreshToken ? identityFromPayload(decodeKimiJwtPayload(refreshToken)) : {}; + // Prefer access claims; fill gaps from refresh (opaque/partial access JWTs). + const accountId = fromAccess.accountId ?? fromRefresh.accountId; + const email = fromAccess.email ?? fromRefresh.email; + return { + ...(accountId ? { accountId } : {}), + ...(email ? { email } : {}), + }; +} + +function identityFromPayload(payload: KimiJwtPayload | undefined): { accountId?: string; email?: string } { + if (!payload) return {}; + const accountId = nonEmptyString(payload.user_id) ?? nonEmptyString(payload.sub); + const emailRaw = nonEmptyString(payload.email); + return { + ...(accountId ? { accountId } : {}), + ...(emailRaw ? { email: emailRaw.toLowerCase() } : {}), + }; +} + function resolveOAuthHost(): string { return process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST; } @@ -109,7 +156,13 @@ function parseTokenPayload(payload: TokenResponse, refreshFallback?: string): OA } const refresh = payload.refresh_token ?? refreshFallback; if (!refresh) throw new Error("Kimi token response missing refresh token"); - return { access: payload.access_token, refresh, expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS }; + const identity = identityFromKimiTokens(payload.access_token, refresh); + return { + access: payload.access_token, + refresh, + expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS, + ...identity, + }; } async function pollForToken(deviceCode: string, intervalMs: number, expiresInMs: number, signal?: AbortSignal): Promise { diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 4f49eee1..bd0e21db 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -10,10 +10,10 @@ * Exceptions: * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot * for Codex pool logins, which have their own ledger (codex-accounts.json). - * - Credentials without identity (no accountId/email — kimi, kiro) replace the active slot + * - Credentials without identity (no accountId/email — e.g. kiro) replace the active slot * instead of appending: their refresh tokens rotate, so a derived id would duplicate the - * same human on every re-login. Cursor login extracts JWT `sub` as accountId so multiauth - * can append distinct accounts. + * same human on every re-login. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor + * extracts JWT `sub` — both append distinct accounts under multiauth. */ import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; diff --git a/tests/kimi-oauth-identity.test.ts b/tests/kimi-oauth-identity.test.ts new file mode 100644 index 00000000..3f5f23da --- /dev/null +++ b/tests/kimi-oauth-identity.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { identityFromKimiTokens } from "../src/oauth/kimi"; +import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; + +const TEST_DIR = join(import.meta.dir, ".tmp-kimi-oauth-identity-test"); +let previousOpencodexHome: string | undefined; + +function jwtWithClaims(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `${header}.${payload}.sig`; +} + +describe("Kimi OAuth JWT identity", () => { + test("user_id becomes accountId", () => { + const access = jwtWithClaims({ user_id: "kimi-user-aaa", exp: 9_999_999_999 }); + expect(identityFromKimiTokens(access)).toEqual({ accountId: "kimi-user-aaa" }); + }); + + test("sub is used when user_id is absent", () => { + const access = jwtWithClaims({ sub: "kimi-sub-bbb" }); + expect(identityFromKimiTokens(access)).toEqual({ accountId: "kimi-sub-bbb" }); + }); + + test("user_id wins over sub", () => { + const access = jwtWithClaims({ user_id: "from-user-id", sub: "from-sub" }); + expect(identityFromKimiTokens(access).accountId).toBe("from-user-id"); + }); + + test("email is lowercased when present", () => { + // Build without an email-shaped source literal (privacy-scan). + const mixed = ["Alice", String.fromCharCode(64), "Kimi.Example"].join(""); + const access = jwtWithClaims({ user_id: "u1", email: mixed }); + expect(identityFromKimiTokens(access).email).toBe(mixed.toLowerCase()); + }); + + test("falls back to refresh JWT when access has no identity", () => { + const access = jwtWithClaims({ scope: "coding" }); + const refresh = jwtWithClaims({ user_id: "from-refresh" }); + expect(identityFromKimiTokens(access, refresh)).toEqual({ accountId: "from-refresh" }); + }); + + test("opaque tokens yield no identity", () => { + expect(identityFromKimiTokens("not-a-jwt", "also-opaque")).toEqual({}); + }); +}); + +describe("Kimi multiauth via saveCredential", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("two distinct user_ids append two kimi accounts", async () => { + const accessA = jwtWithClaims({ user_id: "kimi-a" }); + const accessB = jwtWithClaims({ user_id: "kimi-b" }); + await saveCredential("kimi", { + access: accessA, + refresh: "refresh-a", + expires: Date.now() + 3600_000, + ...identityFromKimiTokens(accessA), + }); + await saveCredential("kimi", { + access: accessB, + refresh: "refresh-b", + expires: Date.now() + 3600_000, + ...identityFromKimiTokens(accessB), + }); + expect(listAccounts("kimi").length).toBe(2); + expect(getCredential("kimi")?.accountId).toBe("kimi-b"); + expect(getCredential("kimi")?.access).toBe(accessB); + }); + + test("same user_id upserts without duplicating", async () => { + const access1 = jwtWithClaims({ user_id: "kimi-same" }); + const access2 = jwtWithClaims({ user_id: "kimi-same", iat: 2 }); + await saveCredential("kimi", { + access: access1, + refresh: "refresh-1", + expires: Date.now() + 3600_000, + ...identityFromKimiTokens(access1), + }); + await saveCredential("kimi", { + access: access2, + refresh: "refresh-2", + expires: Date.now() + 3600_000, + ...identityFromKimiTokens(access2), + }); + expect(listAccounts("kimi").length).toBe(1); + expect(getCredential("kimi")?.access).toBe(access2); + expect(getCredential("kimi")?.refresh).toBe("refresh-2"); + }); + + test("identity-less kimi replace mutates active only and keeps siblings", async () => { + const accessA = jwtWithClaims({ user_id: "kimi-keep" }); + const accessB = jwtWithClaims({ user_id: "kimi-active" }); + await saveCredential("kimi", { + access: accessA, + refresh: "refresh-a", + expires: Date.now() + 3600_000, + ...identityFromKimiTokens(accessA), + }); + await saveCredential("kimi", { + access: accessB, + refresh: "refresh-b", + expires: Date.now() + 3600_000, + ...identityFromKimiTokens(accessB), + }); + expect(listAccounts("kimi").length).toBe(2); + + // Opaque re-login: no accountId → replace active slot in place (does not wipe sibling). + await saveCredential("kimi", { + access: "opaque-access", + refresh: "opaque-refresh", + expires: Date.now() + 3600_000, + }); + expect(listAccounts("kimi").length).toBe(2); + expect(getCredential("kimi")?.access).toBe("opaque-access"); + expect(listAccounts("kimi").some(a => a.credential.accountId === "kimi-keep")).toBe(true); + }); +});