diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..2c65e2f --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,102 @@ +import { + OAUTH_CLIENT_ID, + OAUTH_DEVICE_AUTH_URL, + OAUTH_DEVICE_GRANT, + OAUTH_REFRESH_GRANT, + OAUTH_SCOPE, + OAUTH_TOKEN_URL, +} from "./constants.ts" +import { kimiHeaders } from "./headers.ts" + +export type DeviceAuth = { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete?: string + expires_in: number + interval: number +} + +export type TokenResponse = { + access_token: string + refresh_token: string + token_type: string + expires_in: number +} + +function formBody(params: Record): string { + return new URLSearchParams(params).toString() +} + +async function postForm(url: string, params: Record): Promise { + const res = await fetch(url, { + method: "POST", + headers: { + ...kimiHeaders(), + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: formBody(params), + }) + const text = await res.text() + let json: any + try { + json = text ? JSON.parse(text) : {} + } catch { + throw new Error(`kimi oauth: non-JSON response from ${url} (status ${res.status}): ${text.slice(0, 200)}`) + } + if (!res.ok) { + const code = json.error ?? res.status + const msg = json.error_description ?? text + const err = new Error(`kimi oauth ${code}: ${msg}`) as Error & { code?: string; status?: number } + err.code = json.error + err.status = res.status + throw err + } + return json as T +} + +export async function startDeviceAuth(): Promise { + return postForm(OAUTH_DEVICE_AUTH_URL, { + client_id: OAUTH_CLIENT_ID, + scope: OAUTH_SCOPE, + }) +} + +/** + * Polls the token endpoint until the user approves the device code, the + * device code expires, or an unexpected error occurs. Honors `authorization_pending` + * and `slow_down` per RFC 8628. + */ +export async function pollDeviceToken(device: DeviceAuth): Promise { + let interval = Math.max(1, device.interval || 5) * 1000 + const deadline = Date.now() + device.expires_in * 1000 + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, interval)) + try { + return await postForm(OAUTH_TOKEN_URL, { + client_id: OAUTH_CLIENT_ID, + device_code: device.device_code, + grant_type: OAUTH_DEVICE_GRANT, + }) + } catch (err) { + const code = (err as { code?: string }).code + if (code === "authorization_pending") continue + if (code === "slow_down") { + interval += 5_000 + continue + } + if (code === "expired_token") throw new Error("kimi oauth: device code expired — run login again") + throw err + } + } + throw new Error("kimi oauth: device code expired before the user approved it") +} + +export async function refreshToken(refresh: string): Promise { + return postForm(OAUTH_TOKEN_URL, { + client_id: OAUTH_CLIENT_ID, + refresh_token: refresh, + grant_type: OAUTH_REFRESH_GRANT, + }) +}