feat(ai): add Kimi Code subscription OAuth login (#6935)
Add a device authorization grant flow (RFC 8628) for the kimi-coding provider, mirroring the official Kimi Code CLI: device authorization and token polling against https://auth.kimi.com, refresh-token rotation with retry/backoff, and Bearer auth via toAuth headers. The provider now offers 'Sign in with Kimi Code' alongside the existing KIMI_API_KEY auth. Host is overridable via KIMI_CODE_OAUTH_HOST / KIMI_OAUTH_HOST. Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
|
||||
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Added Kimi Code subscription OAuth login (device authorization grant) for the `kimi-coding` provider, with token refresh and `KIMI_CODE_OAUTH_HOST`/`KIMI_OAUTH_HOST` host overrides.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Kimi Code (subscription) OAuth flow
|
||||
*
|
||||
* RFC 8628 device authorization grant against https://auth.kimi.com with JSON
|
||||
* responses. The access token authenticates requests to
|
||||
* https://api.kimi.com/coding as an `Authorization: Bearer` header.
|
||||
*/
|
||||
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
|
||||
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
|
||||
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
const REFRESH_MAX_RETRIES = 3;
|
||||
|
||||
type DeviceAuthorization = {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
intervalSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
function getOauthHost(): string {
|
||||
const override = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST");
|
||||
return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function requestSignal(signal?: AbortSignal): AbortSignal {
|
||||
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(signal ? [signal] : [])]);
|
||||
}
|
||||
|
||||
function formUrlEncode(fields: Record<string, string>): string {
|
||||
return new URLSearchParams(fields).toString();
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const json = await response.json();
|
||||
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
|
||||
function trustedHttpUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startDeviceAuthorization(oauthHost: string, signal?: AbortSignal): Promise<DeviceAuthorization> {
|
||||
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formUrlEncode({ client_id: CLIENT_ID }),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Kimi Code device authorization failed with status ${response.status}${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
const json = await readJson(response);
|
||||
const deviceCode = json?.device_code;
|
||||
const userCode = json?.user_code;
|
||||
const verificationUri = json?.verification_uri;
|
||||
const verificationUriComplete = json?.verification_uri_complete;
|
||||
if (
|
||||
typeof deviceCode !== "string" ||
|
||||
typeof userCode !== "string" ||
|
||||
typeof verificationUri !== "string" ||
|
||||
typeof verificationUriComplete !== "string" ||
|
||||
!trustedHttpUrl(verificationUriComplete) ||
|
||||
!trustedHttpUrl(verificationUri)
|
||||
) {
|
||||
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
const interval = json?.interval;
|
||||
const expiresIn = json?.expires_in;
|
||||
return {
|
||||
deviceCode,
|
||||
userCode,
|
||||
verificationUri,
|
||||
verificationUriComplete,
|
||||
intervalSeconds:
|
||||
typeof interval === "number" && Number.isFinite(interval) && interval > 0
|
||||
? interval
|
||||
: DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
expiresInSeconds:
|
||||
typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0
|
||||
? expiresIn
|
||||
: DEVICE_CODE_TIMEOUT_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokenResponse(json: Record<string, unknown> | null, operation: string): TokenResponse {
|
||||
const accessToken = json?.access_token;
|
||||
const refreshToken = json?.refresh_token;
|
||||
const expiresIn = json?.expires_in;
|
||||
if (
|
||||
typeof accessToken !== "string" ||
|
||||
!accessToken ||
|
||||
typeof refreshToken !== "string" ||
|
||||
!refreshToken ||
|
||||
typeof expiresIn !== "number" ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
throw new Error(`Kimi Code token ${operation} response missing fields: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return {
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollForToken(
|
||||
oauthHost: string,
|
||||
device: DeviceAuthorization,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TokenResponse> {
|
||||
return pollOAuthDeviceCodeFlow<TokenResponse>({
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
waitBeforeFirstPoll: true,
|
||||
signal,
|
||||
poll: async () => {
|
||||
const response = await fetch(`${oauthHost}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formUrlEncode({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: device.deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
|
||||
if (response.status >= 500) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
status: "failed",
|
||||
message: `Kimi Code device token request failed with status ${response.status}${text ? `: ${text}` : ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
const json = await readJson(response);
|
||||
if (response.ok && typeof json?.access_token === "string") {
|
||||
try {
|
||||
return { status: "complete", value: parseTokenResponse(json, "poll") };
|
||||
} catch (error) {
|
||||
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const error = json?.error;
|
||||
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
||||
if (error === "authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (error === "slow_down") {
|
||||
const interval = json?.interval;
|
||||
return {
|
||||
status: "slow_down",
|
||||
intervalSeconds: typeof interval === "number" && interval > 0 ? interval : undefined,
|
||||
};
|
||||
}
|
||||
if (error === "expired_token") {
|
||||
return { status: "failed", message: "Kimi Code device authorization expired. Please restart login." };
|
||||
}
|
||||
if (error === "access_denied") {
|
||||
return { status: "failed", message: "Kimi Code login was denied." };
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: `Kimi Code device token request failed (status ${response.status})${typeof error === "string" ? `: ${error}${description}` : ""}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetryableRefreshFailure(response: Response): boolean {
|
||||
return response.status === 429 || response.status >= 500;
|
||||
}
|
||||
|
||||
async function refreshToken(
|
||||
oauthHost: string,
|
||||
refreshTokenValue: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TokenResponse> {
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await sleep(1000 * 2 ** (attempt - 1));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Kimi Code token refresh aborted");
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${oauthHost}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formUrlEncode({
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshTokenValue,
|
||||
}),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = await readJson(response);
|
||||
if (response.ok) {
|
||||
return parseTokenResponse(json, "refresh");
|
||||
}
|
||||
|
||||
// Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
|
||||
if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
|
||||
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
||||
throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`);
|
||||
}
|
||||
|
||||
if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
|
||||
lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = JSON.stringify(json);
|
||||
throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Kimi Code token refresh failed");
|
||||
}
|
||||
|
||||
async function loginKimiCoding(interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||
const oauthHost = getOauthHost();
|
||||
const device = await startDeviceAuthorization(oauthHost, interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.userCode,
|
||||
verificationUri: device.verificationUriComplete,
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
});
|
||||
const token = await pollForToken(oauthHost, device, interaction.signal);
|
||||
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
|
||||
}
|
||||
|
||||
export const kimiCodingOAuth: OAuthAuth = {
|
||||
name: "Kimi Code (subscription)",
|
||||
loginLabel: "Sign in with Kimi Code",
|
||||
|
||||
login: loginKimiCoding,
|
||||
|
||||
refresh: async (credential, signal) => {
|
||||
const token = await refreshToken(getOauthHost(), credential.refresh, signal);
|
||||
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
|
||||
},
|
||||
|
||||
async toAuth(credential) {
|
||||
return { headers: { Authorization: `Bearer ${credential.access}` } };
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
|
||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||
};
|
||||
@@ -41,6 +42,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||
};
|
||||
|
||||
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.kimiCoding();
|
||||
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||
};
|
||||
|
||||
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.xai();
|
||||
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
||||
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
|
||||
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
||||
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||
@@ -11,6 +12,7 @@ export function registerBunOAuthFlows(): void {
|
||||
anthropic: () => anthropicOAuth,
|
||||
openaiCodex: () => openaiCodexOAuth,
|
||||
githubCopilot: () => githubCopilotOAuth,
|
||||
kimiCoding: () => kimiCodingOAuth,
|
||||
xai: () => xaiOAuth,
|
||||
radius: createRadiusOAuth,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadKimiCodingOAuth } from "../auth/oauth/load.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts";
|
||||
|
||||
@@ -8,7 +9,14 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> {
|
||||
id: "kimi-coding",
|
||||
name: "Kimi For Coding",
|
||||
baseUrl: "https://api.kimi.com/coding",
|
||||
auth: { apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]) },
|
||||
auth: {
|
||||
apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]),
|
||||
oauth: lazyOAuth({
|
||||
name: "Kimi Code (subscription)",
|
||||
loginLabel: "Sign in with Kimi Code",
|
||||
load: loadKimiCodingOAuth,
|
||||
}),
|
||||
},
|
||||
models: Object.values(KIMI_CODING_MODELS),
|
||||
api: anthropicMessagesApi(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
|
||||
import type { AuthInteraction } from "../src/auth/types.ts";
|
||||
|
||||
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
const OAUTH_HOST = "https://auth.kimi.com";
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function getUrl(input: unknown): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (input instanceof Request) return input.url;
|
||||
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
||||
}
|
||||
|
||||
function deviceAuthorizationResponse(overrides?: Record<string, unknown>): Response {
|
||||
return jsonResponse({
|
||||
user_code: "ABCD-1234",
|
||||
device_code: "device-code-123",
|
||||
verification_uri: "https://www.kimi.com/code",
|
||||
verification_uri_complete: "https://www.kimi.com/code?user_code=ABCD-1234",
|
||||
interval: 5,
|
||||
expires_in: 600,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteraction(events: Array<Record<string, unknown>>): AuthInteraction {
|
||||
return {
|
||||
prompt: async () => {
|
||||
throw new Error("Kimi Code login should not prompt");
|
||||
},
|
||||
notify: (event) => events.push(event),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Kimi Code OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("logs in with the device authorization flow", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startTime = new Date("2026-07-20T00:00:00Z");
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const pollResponses = [
|
||||
jsonResponse({ error: "authorization_pending" }, 400),
|
||||
jsonResponse({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }),
|
||||
];
|
||||
const pollTimes: number[] = [];
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toMatchObject({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
});
|
||||
expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID);
|
||||
return deviceAuthorizationResponse();
|
||||
}
|
||||
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
||||
pollTimes.push(Date.now());
|
||||
const params = new URLSearchParams(String(init?.body));
|
||||
expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
|
||||
expect(params.get("client_id")).toBe(CLIENT_ID);
|
||||
expect(params.get("device_code")).toBe("device-code-123");
|
||||
const response = pollResponses.shift();
|
||||
if (!response) throw new Error("Unexpected extra token poll");
|
||||
return response;
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialPromise = kimiCodingOAuth.login(createInteraction(events));
|
||||
for (let i = 0; i < 5 && events.length === 0; i++) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "device_code",
|
||||
userCode: "ABCD-1234",
|
||||
verificationUri: "https://www.kimi.com/code?user_code=ABCD-1234",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 600,
|
||||
},
|
||||
]);
|
||||
|
||||
// waitBeforeFirstPoll: first poll happens after the 5s interval.
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(pollTimes).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await expect(credentialPromise).resolves.toEqual({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: startTime.getTime() + 10000 + 3600 * 1000,
|
||||
});
|
||||
expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10000]);
|
||||
});
|
||||
|
||||
it("fails when the device code expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
||||
return deviceAuthorizationResponse();
|
||||
}
|
||||
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
||||
return jsonResponse({ error: "expired_token" }, 400);
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
||||
const assertion = expect(credentialPromise).rejects.toThrow("expired");
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("fails when the user denies the login", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
||||
return deviceAuthorizationResponse();
|
||||
}
|
||||
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
||||
return jsonResponse({ error: "access_denied" }, 400);
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
||||
const assertion = expect(credentialPromise).rejects.toThrow("denied");
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("honors the KIMI_CODE_OAUTH_HOST override", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("KIMI_CODE_OAUTH_HOST", "https://auth.example.com/");
|
||||
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
urls.push(url);
|
||||
if (url === "https://auth.example.com/api/oauth/device_authorization") {
|
||||
return deviceAuthorizationResponse({ interval: 1 });
|
||||
}
|
||||
if (url === "https://auth.example.com/api/oauth/token") {
|
||||
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(credentialPromise).resolves.toMatchObject({ access: "a", refresh: "r" });
|
||||
expect(urls).toEqual([
|
||||
"https://auth.example.com/api/oauth/device_authorization",
|
||||
"https://auth.example.com/api/oauth/token",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes tokens and returns a Bearer header for requests", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
expect(url).toBe(`${OAUTH_HOST}/api/oauth/token`);
|
||||
const params = new URLSearchParams(String(init?.body));
|
||||
expect(params.get("grant_type")).toBe("refresh_token");
|
||||
expect(params.get("refresh_token")).toBe("old-refresh");
|
||||
expect(params.get("client_id")).toBe(CLIENT_ID);
|
||||
return jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 });
|
||||
}),
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
const credential = await kimiCodingOAuth.refresh({
|
||||
type: "oauth",
|
||||
access: "old-access",
|
||||
refresh: "old-refresh",
|
||||
expires: before,
|
||||
});
|
||||
expect(credential).toEqual({
|
||||
type: "oauth",
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: expect.any(Number),
|
||||
});
|
||||
expect(credential.expires).toBeGreaterThanOrEqual(before + 3600 * 1000);
|
||||
|
||||
await expect(kimiCodingOAuth.toAuth(credential)).resolves.toEqual({
|
||||
headers: { Authorization: "Bearer new-access" },
|
||||
});
|
||||
});
|
||||
|
||||
it("retries refresh on 429 and fails unauthorized on invalid_grant", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// 429 once, then success.
|
||||
let calls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (): Promise<Response> => {
|
||||
calls += 1;
|
||||
if (calls === 1) return jsonResponse({ error: "temporarily_unavailable" }, 429);
|
||||
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshPromise = kimiCodingOAuth.refresh({
|
||||
type: "oauth",
|
||||
access: "old",
|
||||
refresh: "old",
|
||||
expires: 0,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(refreshPromise).resolves.toMatchObject({ access: "a" });
|
||||
expect(calls).toBe(2);
|
||||
|
||||
// invalid_grant is not retried.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (): Promise<Response> => jsonResponse({ error: "invalid_grant" }, 400)),
|
||||
);
|
||||
await expect(
|
||||
kimiCodingOAuth.refresh({ type: "oauth", access: "old", refresh: "old", expires: 0 }),
|
||||
).rejects.toThrow("unauthorized");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user