mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-14 19:25:35 +00:00
feat(oauth): send the product User-Agent on OAuth requests
The OAuth endpoints used to receive only the X-Msh-* device headers (undici's default UA otherwise), which left the OAuth host unable to distinguish runtime surfaces — notably kimi web, whose platform matches the CLI and whose only distinguishing mark is the (web) UA suffix. The toolkit now feeds the full identity headers (User-Agent + X-Msh-*) into every device authorization, token polling, and refresh request; the request-header type widens from DeviceHeaders to OAuthRequestHeaders.
This commit is contained in:
parent
84af131802
commit
bad0bab0be
5 changed files with 22 additions and 15 deletions
|
|
@ -2,4 +2,4 @@
|
|||
"@moonshot-ai/kimi-code-oauth": minor
|
||||
---
|
||||
|
||||
Rework the host identity type: rename `userAgentProduct` to `productName` and add a required `platform` field, so every host explicitly declares the `X-Msh-Platform` value it reports instead of silently inheriting the CLI's.
|
||||
Rework the host identity type: rename `userAgentProduct` to `productName` and add a required `platform` field, so every host explicitly declares the `X-Msh-Platform` value it reports instead of silently inheriting the CLI's. OAuth requests now also send the product User-Agent (with the optional runtime suffix), so the OAuth host can tell client families and surfaces apart.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from
|
|||
import type { DevicePollResult, RefreshOptions } from './oauth';
|
||||
import type { TokenStorage } from './storage';
|
||||
import { classifyToken, revokedTombstone, type TokenState } from './token-state';
|
||||
import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types';
|
||||
import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, OAuthRequestHeaders, TokenInfo } from './types';
|
||||
|
||||
const MIN_REFRESH_THRESHOLD_SECONDS = 300;
|
||||
const REFRESH_THRESHOLD_RATIO = 0.5;
|
||||
|
|
@ -68,7 +68,7 @@ export interface OAuthManagerOptions {
|
|||
readonly pollDeviceImpl?:
|
||||
| ((config: OAuthFlowConfig, deviceCode: string) => Promise<DevicePollResult>)
|
||||
| undefined;
|
||||
readonly deviceHeaders?: (() => DeviceHeaders | undefined) | undefined;
|
||||
readonly deviceHeaders?: (() => OAuthRequestHeaders | undefined) | undefined;
|
||||
/**
|
||||
* Root directory for per-provider lock files; resolves to
|
||||
* `{configDir}/oauth/{providerName}.lock`.
|
||||
|
|
@ -104,7 +104,7 @@ export class OAuthManager {
|
|||
private readonly refreshImpl: NonNullable<OAuthManagerOptions['refreshTokenImpl']>;
|
||||
private readonly requestImpl: NonNullable<OAuthManagerOptions['requestDeviceImpl']>;
|
||||
private readonly pollImpl: NonNullable<OAuthManagerOptions['pollDeviceImpl']>;
|
||||
private readonly deviceHeaders: (() => DeviceHeaders | undefined) | undefined;
|
||||
private readonly deviceHeaders: (() => OAuthRequestHeaders | undefined) | undefined;
|
||||
private readonly configDir: string | undefined;
|
||||
private readonly onRefresh: ((outcome: OAuthRefreshOutcome) => void) | undefined;
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ export class OAuthManager {
|
|||
this.configDir = options.configDir ?? envConfigDir;
|
||||
}
|
||||
|
||||
private resolveDeviceHeaders(): DeviceHeaders | undefined {
|
||||
private resolveDeviceHeaders(): OAuthRequestHeaders | undefined {
|
||||
return this.deviceHeaders?.();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
OAuthUnauthorizedError,
|
||||
RetryableRefreshError,
|
||||
} from './errors';
|
||||
import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types';
|
||||
import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, OAuthRequestHeaders, TokenInfo } from './types';
|
||||
import { isRecord } from './utils';
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
||||
|
|
@ -59,7 +59,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
|
|||
async function postForm(
|
||||
url: string,
|
||||
params: Record<string, string>,
|
||||
deviceHeaders?: DeviceHeaders | undefined,
|
||||
deviceHeaders?: OAuthRequestHeaders | undefined,
|
||||
options?: { timeoutMs?: number; signal?: AbortSignal },
|
||||
): Promise<{ status: number; data: Record<string, unknown> }> {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
|
||||
|
|
@ -118,7 +118,7 @@ function describeFetchFailure(error: unknown): string {
|
|||
|
||||
export async function requestDeviceAuthorization(
|
||||
config: OAuthFlowConfig,
|
||||
options: { readonly deviceHeaders?: DeviceHeaders | undefined },
|
||||
options: { readonly deviceHeaders?: OAuthRequestHeaders | undefined },
|
||||
): Promise<DeviceAuthorization> {
|
||||
const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/device_authorization`;
|
||||
const { status, data } = await postForm(
|
||||
|
|
@ -168,7 +168,7 @@ export type DevicePollResult =
|
|||
export async function pollDeviceToken(
|
||||
config: OAuthFlowConfig,
|
||||
deviceCode: string,
|
||||
options: { readonly deviceHeaders?: DeviceHeaders | undefined },
|
||||
options: { readonly deviceHeaders?: OAuthRequestHeaders | undefined },
|
||||
): Promise<DevicePollResult> {
|
||||
const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/token`;
|
||||
const { status, data } = await postForm(
|
||||
|
|
@ -213,7 +213,7 @@ export async function pollDeviceToken(
|
|||
// ── refreshAccessToken ────────────────────────────────────────────────
|
||||
|
||||
export interface RefreshOptions {
|
||||
readonly deviceHeaders?: DeviceHeaders | undefined;
|
||||
readonly deviceHeaders?: OAuthRequestHeaders | undefined;
|
||||
readonly maxRetries?: number | undefined;
|
||||
/**
|
||||
* Backoff between retries in ms. Defaults to `2 ** attempt * 1000` (1s, 2s).
|
||||
|
|
@ -289,4 +289,4 @@ export async function refreshAccessToken(
|
|||
throw lastError ?? new OAuthError('Token refresh failed after retries.');
|
||||
}
|
||||
|
||||
export type { DeviceHeaders };
|
||||
export type { DeviceHeaders, OAuthRequestHeaders };
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { OAuthUnauthorizedError } from './errors';
|
|||
import {
|
||||
assertKimiHostIdentity,
|
||||
createKimiDefaultHeaders,
|
||||
createKimiDeviceHeaders,
|
||||
type KimiHostIdentity,
|
||||
} from './identity';
|
||||
import {
|
||||
|
|
@ -393,10 +392,12 @@ export class KimiOAuthToolkit<TConfig = unknown> {
|
|||
identity === undefined
|
||||
? undefined
|
||||
: () =>
|
||||
createKimiDeviceHeaders({
|
||||
// Full identity headers (User-Agent + X-Msh-*): the OAuth host
|
||||
// reads the platform for the client family and the UA (suffix)
|
||||
// for the runtime surface, e.g. kimi web's `(web)`.
|
||||
createKimiDefaultHeaders({
|
||||
homeDir: this.homeDir,
|
||||
version: identity.version,
|
||||
platform: identity.platform,
|
||||
...identity,
|
||||
}),
|
||||
...this.managerOptions,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ export interface DeviceHeaders {
|
|||
readonly 'X-Msh-Device-Id': string;
|
||||
}
|
||||
|
||||
/** Headers sent with OAuth HTTP requests: the `X-Msh-*` device set, plus a
|
||||
product User-Agent when the caller carries a host identity — the OAuth
|
||||
host needs both to tell client families (platform) and runtime surfaces
|
||||
(UA suffix) apart. */
|
||||
export type OAuthRequestHeaders = Record<string, string>;
|
||||
|
||||
/** JSON wire format for token persistence (snake_case, Python-compatible). */
|
||||
export interface TokenInfoWire {
|
||||
readonly access_token: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue