Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion src/oauth/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<OAuthCredentials> {
Expand Down
6 changes: 3 additions & 3 deletions src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
131 changes: 131 additions & 0 deletions tests/kimi-oauth-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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);
});
});
Loading