"""Kimi Code OAuth device-flow 客户端(K1.3 / PROGRESS K1)。 Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**(RFC 8628): 1. `start_device_authorization` → `POST .../device_authorization`,拿 `device_code` + `user_code` + `verification_uri`(用户在浏览器授权)+ 轮询 `interval`/过期 `expires_in`。 2. `poll_token`(一次尝试,调用方按 `interval` 循环)→ `POST .../token` (grant=device_code);`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、 `expired_token`/`access_denied` → 停止失败;成功 → access/refresh token。 3. `refresh` → 同 token 端点(grant=refresh_token),换新的 access/refresh token。 **httpx 注入**:所有 HTTP 经注入的 `AsyncHttpClient` Protocol(= `httpx.AsyncClient` 的 `.post` 子集)——测试注 fake,**绝不联网**。 **token 不落明文**:`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc` (`encrypt_oauth_bundle`/`decrypt_oauth_bundle`),明文 token 绝不进日志/响应/job 结果。 研究确认(对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`): - client_id `17e5f671-d194-4dfb-9706-5516cb48c098`(env `KIMI_CLIENT_ID` 可覆盖)。 - device authorization **带 `scope=kimi-code`**(coding-agent OAuth scope):opencode-kimi `constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token 缺 coding entitlement,调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。 - scope 只放在 device_authorization 请求上;token 交换/刷新不带 scope(OAuth device flow 惯例)。 """ from __future__ import annotations import json import os from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Any, Protocol from ww_shared import AppError, ErrorCode from ww_api.security.credentials import decrypt_api_key, encrypt_api_key #: Kimi OAuth 端点(device authorization + token)。 KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth" DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization" TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token" #: 默认 client_id(kimi-cli 公开常量;env `KIMI_CLIENT_ID` 可覆盖)。 DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" ENV_CLIENT_ID = "KIMI_CLIENT_ID" GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" GRANT_REFRESH_TOKEN = "refresh_token" #: coding-agent OAuth scope(device authorization 专用;缺它 token 无 coding entitlement)。 KIMI_CODE_SCOPE = "kimi-code" #: device flow 默认轮询间隔(秒)——服务端未给 `interval` 时的兜底。 DEFAULT_POLL_INTERVAL = 5 #: token 刷新触发缓冲(秒):剩余寿命低于 max(300, 0.5*expires_in) 即刷新。 MIN_REFRESH_BUFFER_SECONDS = 300 def client_id() -> str: """当前 OAuth client_id(env `KIMI_CLIENT_ID` 优先,否则公开默认值)。""" return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID class AsyncHttpClient(Protocol): """`run_job`/服务对 HTTP 客户端的**最小**依赖(= `httpx.AsyncClient.post` 子集)。 便于测试注 fake(绝不联网)。运行时传 `httpx.AsyncClient`。 """ async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ... class HttpResponse(Protocol): """HTTP 响应的最小读接口(`httpx.Response` 满足之)。""" status_code: int def json(self) -> Any: ... @dataclass(frozen=True) class DeviceAuth: """device authorization 响应(用户面:展示 user_code + 打开 verification_uri)。""" device_code: str user_code: str verification_uri: str verification_uri_complete: str | None expires_in: int interval: int @dataclass(frozen=True) class TokenSet: """OAuth token 三元组(access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC)。""" access_token: str refresh_token: str expires_at: datetime class AuthorizationPending(Exception): """device flow 轮询:用户尚未授权(继续轮询)。""" class SlowDown(Exception): """device flow 轮询:轮询过快(增大 interval 后继续)。""" def _now() -> datetime: return datetime.now(UTC) def _expires_at(expires_in: int) -> datetime: return _now() + timedelta(seconds=max(0, expires_in)) def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool: """判定 access token 是否临近过期(剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`)。 建网关时按需刷新(§token 刷新启发式);过期时刻已是服务端驱动的绝对时刻,故只需 与缓冲比较(无需原始 expires_in:缓冲固定 300s,对 ~15min access 足够)。 """ current = now or _now() remaining = (token.expires_at - current).total_seconds() return remaining < MIN_REFRESH_BUFFER_SECONDS async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth: """发起 device authorization(带 `scope=kimi-code` 以获取 coding entitlement)。""" resp = await http.post( DEVICE_AUTHORIZATION_URL, data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE}, ) if resp.status_code >= 400: raise AppError( ErrorCode.LLM_UNAVAILABLE, "Kimi 设备授权请求失败", {"status": resp.status_code}, ) body = resp.json() return DeviceAuth( device_code=str(body["device_code"]), user_code=str(body["user_code"]), verification_uri=str(body["verification_uri"]), verification_uri_complete=( str(body["verification_uri_complete"]) if body.get("verification_uri_complete") else None ), expires_in=int(body.get("expires_in", 0)), interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)), ) def _token_set_from_body(body: dict[str, Any]) -> TokenSet: return TokenSet( access_token=str(body["access_token"]), refresh_token=str(body["refresh_token"]), expires_at=_expires_at(int(body.get("expires_in", 0))), ) async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet: """轮询一次 token 端点(调用方按 interval 循环)。 `authorization_pending` → 抛 `AuthorizationPending`(继续轮询); `slow_down` → 抛 `SlowDown`(增大 interval); `expired_token`/`access_denied`/其它 → 抛 `AppError`(停止失败); 成功 → `TokenSet`。 """ resp = await http.post( TOKEN_URL, data={ "grant_type": GRANT_DEVICE_CODE, "device_code": device_code, "client_id": client_id(), }, ) body = resp.json() if resp.status_code >= 400 or body.get("error"): error = str(body.get("error", "unknown_error")) if error == "authorization_pending": raise AuthorizationPending if error == "slow_down": raise SlowDown # expired_token / access_denied / 其它 → 终止失败。 raise AppError( ErrorCode.LLM_UNAVAILABLE, f"Kimi 设备授权失败:{error}", {"error": error}, ) return _token_set_from_body(body) async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet: """用 refresh_token 换新 token 组(access 临近过期时建网关触发)。""" resp = await http.post( TOKEN_URL, data={ "grant_type": GRANT_REFRESH_TOKEN, "refresh_token": refresh_token, "client_id": client_id(), }, ) body = resp.json() if resp.status_code >= 400 or body.get("error"): error = str(body.get("error", "unknown_error")) raise AppError( ErrorCode.LLM_UNAVAILABLE, f"Kimi token 刷新失败:{error}", {"error": error}, ) return _token_set_from_body(body) def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes: """把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。 JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。 """ bundle = json.dumps( { "access_token": token.access_token, "refresh_token": token.refresh_token, "expires_at": token.expires_at.isoformat(), } ) return encrypt_api_key(bundle, key=key) def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet: """解密 `oauth_enc` 密文回 `TokenSet`(reuse Fernet helper,工作在 str 上)。""" bundle = json.loads(decrypt_api_key(blob, key=key)) return TokenSet( access_token=str(bundle["access_token"]), refresh_token=str(bundle["refresh_token"]), expires_at=datetime.fromisoformat(str(bundle["expires_at"])), )