{"version":3,"sources":["../src/cli/utils/governance/device-flow.ts"],"sourcesContent":["/**\n * RFC 8628 device-code OAuth client for the `langwatch login --device`\n * flow. Targets the control plane's `/api/auth/cli/*` endpoints —\n * the same wire surface a custom CLI client would hit (documented in\n * `docs/ai-gateway/governance/admin-setup.mdx#cli-device-flow-rest-api`).\n *\n * Pure stdlib `fetch`, no axios — matches the rest of the typescript\n * CLI's HTTP style.\n */\n\nimport * as os from \"node:os\";\nimport { setTimeout as wait } from \"node:timers/promises\";\n\nexport interface DeviceCode {\n  device_code: string;\n  user_code: string;\n  verification_uri: string;\n  verification_uri_complete?: string;\n  expires_in: number;\n  interval: number;\n}\n\nexport interface ExchangeUser {\n  id: string;\n  email: string;\n  name: string;\n}\n\nexport interface ExchangeOrganization {\n  id: string;\n  slug?: string;\n  name: string;\n}\n\nexport interface ExchangePersonalVK {\n  id: string;\n  secret: string;\n  prefix?: string;\n}\n\nexport interface ExchangeProject {\n  id: string;\n  slug: string;\n  name: string;\n}\n\n/**\n * The CLI device-code flow can mint two distinct credential types:\n *   - \"device_session\" — the user-scoped OAuth-style access+refresh\n *     token pair used by `langwatch claude/codex/...` wrappers.\n *   - \"project_api_key\" — the project-scoped SDK key\n *     (`Project.apiKey`) returned verbatim, used by SDK consumers\n *     and `langwatch sync/eval/prompt/...` commands.\n *\n * Caller selects via `startDeviceCode({ credentialType })`. Server\n * stamps the choice on the device-code record + flips the response\n * shape on /exchange. Same browser approval ceremony for both — only\n * the persist target differs (`~/.langwatch/config.json` vs `.env`).\n */\nexport type CredentialType = \"device_session\" | \"project_api_key\";\n\nexport interface ExchangeDeviceSessionResult {\n  kind: \"device_session\";\n  access_token: string;\n  refresh_token: string;\n  expires_in: number;\n  user: ExchangeUser;\n  organization: ExchangeOrganization;\n  default_personal_vk?: ExchangePersonalVK;\n  endpoint?: string;\n}\n\nexport interface ExchangeApiKeyResult {\n  kind: \"api_key\";\n  api_key: string;\n  project: ExchangeProject;\n  user: ExchangeUser;\n  organization: ExchangeOrganization;\n  endpoint?: string;\n}\n\nexport type ExchangeResult =\n  | ExchangeDeviceSessionResult\n  | ExchangeApiKeyResult;\n\n/**\n * Back-compat alias for the device-session shape. Pre-`f9fcc3927` server\n * builds returned the unkinded shape verbatim; the runtime normaliser\n * below maps that to `{ kind: 'device_session', ... }` so callers can\n * always assume the discriminated form.\n */\nexport type LegacyExchangeResult = Omit<\n  ExchangeDeviceSessionResult,\n  \"kind\"\n> & { kind?: \"device_session\" };\n\nexport interface RefreshResult {\n  access_token: string;\n  refresh_token: string;\n  expires_in: number;\n}\n\nexport class DeviceFlowError extends Error {\n  constructor(public readonly kind: \"pending\" | \"denied\" | \"expired\" | \"slow_down\" | \"unauthorized\" | \"other\", message: string) {\n    super(message);\n    this.name = \"DeviceFlowError\";\n  }\n}\n\nexport interface DeviceFlowOptions {\n  /** Control-plane base URL (e.g. https://app.langwatch.ai). */\n  baseUrl: string;\n  /** Optional fetch override for tests. */\n  fetchImpl?: typeof fetch;\n}\n\n/**\n * `POST /api/auth/cli/device-code` — mint a device-code + user-code pair.\n *\n * Optional `credentialType` selects what `/exchange` will return on\n * approval: a user-scoped device session (default) or a project-scoped\n * API key. Older servers ignore the field and stay on the device-session\n * shape — back-compat is the server's responsibility.\n */\nexport async function startDeviceCode(\n  opts: DeviceFlowOptions,\n  init: { credentialType?: CredentialType } = {},\n): Promise<DeviceCode> {\n  const body: Record<string, unknown> = {};\n  if (init.credentialType) body.credential_type = init.credentialType;\n  const dc = await postJSON<DeviceCode>(opts, \"/api/auth/cli/device-code\", body);\n  if (!dc.interval || dc.interval <= 0) dc.interval = 5;\n  return dc;\n}\n\n/**\n * Device fingerprint stamped onto the CLI session at /exchange time.\n * The control plane persists it on the access + refresh token records\n * so /me/sessions can render \"Mac (rchaves.local)\" instead of\n * \"Unknown device\" — multi-device users need this to revoke\n * individual sessions without nuking every device they're logged in\n * on (Ariana QA finding). See\n * `langwatch/src/server/routes/auth-cli.ts#clientInfoSchema` for the\n * server contract.\n */\nfunction collectClientInfo(): {\n  hostname: string;\n  uname: string;\n  platform: string;\n} {\n  let hostname = \"\";\n  let uname = \"\";\n  try {\n    hostname = os.hostname();\n  } catch {\n    // os.hostname() can throw on locked-down sandboxes; carry empty.\n  }\n  try {\n    uname = os.userInfo().username;\n  } catch {\n    // os.userInfo() throws when the uid has no /etc/passwd entry\n    // (some Docker images, CI sandboxes); carry empty.\n  }\n  return { hostname, uname, platform: process.platform };\n}\n\n/**\n * `POST /api/auth/cli/exchange` — single poll. Returns the access+refresh\n * token bundle on success (200), or throws DeviceFlowError with a\n * categorised `kind` so the caller can decide whether to keep polling\n * (`pending`, `slow_down`) or stop (`denied`, `expired`).\n */\nexport async function exchange(\n  opts: DeviceFlowOptions,\n  deviceCode: string,\n): Promise<ExchangeResult> {\n  const res = await rawPost(opts, \"/api/auth/cli/exchange\", {\n    device_code: deviceCode,\n    client_info: collectClientInfo(),\n  });\n  switch (res.status) {\n    case 200: {\n      const body = (await res.json()) as\n        | ExchangeResult\n        | LegacyExchangeResult;\n      // Pre-f9fcc3927 servers returned the device-session shape without\n      // a `kind` field. Normalise so callers can always discriminate.\n      if (!(\"kind\" in body) || !body.kind) {\n        return { kind: \"device_session\", ...(body) };\n      }\n      return body as ExchangeResult;\n    }\n    case 428:\n      throw new DeviceFlowError(\"pending\", \"authorization pending\");\n    case 410:\n      throw new DeviceFlowError(\"denied\", \"authorization denied\");\n    case 408:\n      throw new DeviceFlowError(\"expired\", \"authorization request expired\");\n    case 429:\n      throw new DeviceFlowError(\"slow_down\", \"polling too fast\");\n    default: {\n      const body = await res.text().catch(() => \"\");\n      throw new DeviceFlowError(\"other\", `unexpected status ${res.status}: ${body.slice(0, 256)}`);\n    }\n  }\n}\n\n/**\n * Poll `exchange` at the cadence the server requested until the user\n * approves, denies, or the device-code expires. Honours RFC 8628 §3.5\n * by doubling the polling interval on `slow_down` responses.\n */\nexport async function pollUntilDone(\n  opts: DeviceFlowOptions,\n  dc: DeviceCode,\n): Promise<ExchangeResult> {\n  let interval = dc.interval * 1000;\n  const ceiling = 60_000;\n  const deadline = Date.now() + dc.expires_in * 1000;\n\n  for (;;) {\n    if (Date.now() > deadline) {\n      throw new DeviceFlowError(\"expired\", \"authorization request expired\");\n    }\n    await wait(interval);\n    try {\n      return await exchange(opts, dc.device_code);\n    } catch (err) {\n      if (!(err instanceof DeviceFlowError)) throw err;\n      if (err.kind === \"pending\") continue;\n      if (err.kind === \"slow_down\") {\n        interval = Math.min(interval * 2, ceiling);\n        continue;\n      }\n      throw err;\n    }\n  }\n}\n\n/**\n * `POST /api/auth/cli/refresh` — rotate access and refresh tokens.\n * 401 means the refresh token has been revoked server-side (admin\n * disable / off-boarding); the caller should clear local state.\n */\nexport async function refresh(\n  opts: DeviceFlowOptions,\n  refreshToken: string,\n): Promise<RefreshResult> {\n  const res = await rawPost(opts, \"/api/auth/cli/refresh\", { refresh_token: refreshToken });\n  if (res.status === 401) {\n    throw new DeviceFlowError(\"unauthorized\", \"session revoked — re-authenticate\");\n  }\n  if (!res.ok) {\n    const body = await res.text().catch(() => \"\");\n    throw new DeviceFlowError(\"other\", `refresh failed (${res.status}): ${body.slice(0, 256)}`);\n  }\n  return (await res.json()) as RefreshResult;\n}\n\n/**\n * `POST /api/auth/cli/logout` — server-side revoke a refresh token\n * AND its paired access token (per `e7a042c69`: a stolen access\n * token used to survive logout for up to 1h until expiry; sending\n * both closes the gap). Idempotent — 200 on already-revoked or\n * unknown tokens.\n */\nexport async function logout(\n  opts: DeviceFlowOptions,\n  refreshToken: string,\n  accessToken?: string,\n): Promise<void> {\n  const body: Record<string, string> = { refresh_token: refreshToken };\n  if (accessToken) body.access_token = accessToken;\n  const res = await rawPost(opts, \"/api/auth/cli/logout\", body);\n  // 401/404 mean \"already gone\" — that's success for logout.\n  if (res.status === 200 || res.status === 401 || res.status === 404) return;\n  const text = await res.text().catch(() => \"\");\n  throw new DeviceFlowError(\"other\", `logout failed (${res.status}): ${text.slice(0, 256)}`);\n}\n\nasync function postJSON<T>(opts: DeviceFlowOptions, path: string, body: unknown): Promise<T> {\n  const res = await rawPost(opts, path, body);\n  if (!res.ok) {\n    const text = await res.text().catch(() => \"\");\n    throw new DeviceFlowError(\"other\", `${path} → ${res.status}: ${text.slice(0, 256)}`);\n  }\n  return (await res.json()) as T;\n}\n\nfunction rawPost(opts: DeviceFlowOptions, path: string, body: unknown): Promise<Response> {\n  const url = opts.baseUrl.replace(/\\/+$/, \"\") + path;\n  const f = opts.fetchImpl ?? fetch;\n  return f(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Accept: \"application/json\",\n      // Origin enforcement on the server requires this for non-browser\n      // clients. The base URL is the same as the control plane, so\n      // mirroring it as Origin satisfies the same-origin gate.\n      Origin: opts.baseUrl.replace(/\\/+$/, \"\"),\n    },\n    body: JSON.stringify(body ?? {}),\n  });\n}\n"],"mappings":"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","names":["hostname"]}