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:
liruifengv 2026-07-30 03:04:50 +08:00
parent 84af131802
commit bad0bab0be
5 changed files with 22 additions and 15 deletions

View file

@ -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.

View file

@ -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?.();
}

View file

@ -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 };

View file

@ -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,
});

View file

@ -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;