399 lines · 13.7 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// CLI device-login (OAuth 2.0 Device Authorization Grant, RFC 8628).//// `executor login` against a hosted server://   1. discover the provider device endpoints + public client id from the//      server (`GET /api/auth/cli-login`),//   2. request a device code, show the user_code + open the verification URL,//   3. poll the token endpoint until the user approves in the browser,//   4. hand the access + refresh tokens back to be stored in the profile.//// The flow is provider-neutral: the server advertises WorkOS (cloud) or Better// Auth (self-host) endpoints, and both speak the same RFC 8628 wire shape, so// this module never branches on provider. Tokens are sent to the `/api/*`// plane as `Authorization: Bearer <access_token>`.// --------------------------------------------------------------------------- import { spawn } from "node:child_process"; export interface CliLoginDiscovery {  readonly provider: string;  readonly deviceAuthorizationEndpoint: string;  readonly tokenEndpoint: string;  readonly clientId: string;  readonly scope?: string;  /**   * How to encode the device-authorization + token requests. RFC 8628 mandates   * `form` (WorkOS), but some providers' endpoints only accept JSON (Better   * Auth). The server tells us via discovery; defaults to `form`.   */  readonly requestFormat: "form" | "json";} export interface DeviceCodeGrant {  readonly deviceCode: string;  readonly userCode: string;  readonly verificationUri: string;  readonly verificationUriComplete?: string;  readonly expiresInSeconds: number;  readonly intervalSeconds: number;} export interface DeviceTokens {  readonly accessToken: string;  readonly refreshToken?: string;  /** Epoch seconds the access token expires, when known. */  readonly expiresAt?: number;  /** The signed-in account's email, if the token response carries a user. */  readonly email?: string;  /** The bound organization id, from the response or the token's org_id claim. */  readonly organizationId?: string;} export interface DeviceLoginHttpOptions {  readonly headers?: Readonly<Record<string, string>>;  readonly serverOrigin?: string;} export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {  readonly now?: () => number;} const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";const DEFAULT_INTERVAL_SECONDS = 5; export class DeviceLoginError extends Error {  constructor(message: string) {    super(message);    this.name = "DeviceLoginError";  }} const cliLoginUrl = (origin: string): string => `${origin.replace(/\/+$/, "")}/api/auth/cli-login`; const asString = (value: unknown): string | undefined =>  typeof value === "string" && value.length > 0 ? value : undefined; const asNumber = (value: unknown): number | undefined =>  typeof value === "number" && Number.isFinite(value) ? value : undefined; /** Pull `user.email` out of a token response (WorkOS returns the user inline). */const readUserEmail = (body: Record<string, unknown>): string | undefined => {  const user = body.user;  if (user && typeof user === "object" && "email" in user) {    return asString((user as { email?: unknown }).email);  }  return undefined;}; /** * Decode a JWT access token's claims WITHOUT verifying it. The CLI only uses * this for display (`whoami`, the post-login summary) and to read `exp`, the * server is the only thing that verifies the token's signature. */export const decodeAccessTokenClaims = (  accessToken: string,): Record<string, unknown> | undefined => {  const segments = accessToken.split(".");  if (segments.length !== 3) return undefined;  const payloadSegment = segments[1];  if (!payloadSegment) return undefined;  try {    const json = Buffer.from(payloadSegment, "base64url").toString("utf8");    const claims = JSON.parse(json) as unknown;    return claims && typeof claims === "object" ? (claims as Record<string, unknown>) : undefined;  } catch {    return undefined;  }}; /** Read `exp` (epoch seconds) out of a JWT access token without verifying it. */export const accessTokenExpiry = (accessToken: string): number | undefined =>  asNumber(decodeAccessTokenClaims(accessToken)?.exp); const deriveExpiresAt = (tokens: {  accessToken: string;  expiresIn?: number;}): number | undefined => {  if (tokens.expiresIn !== undefined) {    return Math.floor(Date.now() / 1000) + tokens.expiresIn;  }  return accessTokenExpiry(tokens.accessToken);}; const formBody = (fields: Record<string, string | undefined>): string => {  const params = new URLSearchParams();  for (const [key, value] of Object.entries(fields)) {    if (value !== undefined) params.set(key, value);  }  return params.toString();}; const definedFields = (fields: Record<string, string | undefined>): Record<string, string> =>  Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)) as Record<    string,    string  >; const isSameOrigin = (url: string, origin: string): boolean => {  try {    return new URL(url).origin === new URL(origin).origin;  } catch {    return false;  }}; const headersForUrl = (  url: string,  baseHeaders: Record<string, string>,  options: DeviceLoginHttpOptions = {},): Record<string, string> => {  const configured =    options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))      ? options.headers      : {};  return { ...configured, ...baseHeaders };}; const post = async (  url: string,  fields: Record<string, string | undefined>,  format: "form" | "json",  options: DeviceLoginHttpOptions = {},) =>  fetch(url, {    method: "POST",    headers: headersForUrl(      url,      {        "content-type":          format === "json" ? "application/json" : "application/x-www-form-urlencoded",        accept: "application/json",      },      options,    ),    body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),  }); const readJson = async (response: Response): Promise<Record<string, unknown>> => {  const text = await response.text();  if (!text) return {};  try {    return JSON.parse(text) as Record<string, unknown>;  } catch {    return { error: "invalid_response", error_description: text.slice(0, 200) };  }}; export const discoverCliLogin = async (  origin: string,  options: DeviceLoginHttpOptions = {},): Promise<CliLoginDiscovery> => {  let response: Response;  const url = cliLoginUrl(origin);  try {    response = await fetch(url, {      headers: headersForUrl(url, { accept: "application/json" }, options),    });  } catch (cause) {    throw new DeviceLoginError(      `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,    );  }  if (!response.ok) {    throw new DeviceLoginError(      `${origin} does not support CLI login (GET /api/auth/cli-login returned ${response.status}). ` +        "It may be an older server, or a local/unauthenticated server that needs no login.",    );  }  const body = await readJson(response);  const deviceAuthorizationEndpoint = asString(body.deviceAuthorizationEndpoint);  const tokenEndpoint = asString(body.tokenEndpoint);  const clientId = asString(body.clientId);  if (!deviceAuthorizationEndpoint || !tokenEndpoint || !clientId) {    throw new DeviceLoginError(`${origin} returned an incomplete CLI-login configuration.`);  }  return {    provider: asString(body.provider) ?? "unknown",    deviceAuthorizationEndpoint,    tokenEndpoint,    clientId,    scope: asString(body.scope),    requestFormat: asString(body.requestFormat) === "json" ? "json" : "form",  };}; export const requestDeviceCode = async (  discovery: CliLoginDiscovery,  options: DeviceLoginHttpOptions = {},): Promise<DeviceCodeGrant> => {  const response = await post(    discovery.deviceAuthorizationEndpoint,    { client_id: discovery.clientId, scope: discovery.scope },    discovery.requestFormat,    options,  );  const body = await readJson(response);  if (!response.ok) {    throw new DeviceLoginError(      `Device authorization failed (${response.status}): ${asString(body.error_description) ?? asString(body.error) ?? "unknown error"}`,    );  }  const deviceCode = asString(body.device_code);  const userCode = asString(body.user_code);  const verificationUri = asString(body.verification_uri);  if (!deviceCode || !userCode || !verificationUri) {    throw new DeviceLoginError("Device authorization response was missing required fields.");  }  return {    deviceCode,    userCode,    verificationUri,    verificationUriComplete: asString(body.verification_uri_complete),    expiresInSeconds: asNumber(body.expires_in) ?? 300,    intervalSeconds: asNumber(body.interval) ?? DEFAULT_INTERVAL_SECONDS,  };}; const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); /** * Poll the token endpoint until the user approves, honoring the RFC 8628 * pacing rules (`authorization_pending` keeps waiting, `slow_down` backs off, * `access_denied` / `expired_token` are terminal). */export const pollForDeviceTokens = async (  discovery: CliLoginDiscovery,  grant: DeviceCodeGrant,  options: PollForDeviceTokensOptions = {},): Promise<DeviceTokens> => {  const now = options.now ?? (() => Date.now());  const deadline = now() + grant.expiresInSeconds * 1000;  let intervalMs = Math.max(1, grant.intervalSeconds) * 1000;   for (;;) {    if (now() >= deadline) {      throw new DeviceLoginError("Login timed out before it was approved.");    }    await sleep(intervalMs);     const response = await post(      discovery.tokenEndpoint,      {        grant_type: DEVICE_CODE_GRANT_TYPE,        device_code: grant.deviceCode,        client_id: discovery.clientId,      },      discovery.requestFormat,      options,    );    const body = await readJson(response);     if (response.ok) {      const accessToken = asString(body.access_token);      if (!accessToken) {        throw new DeviceLoginError("Token response was missing an access token.");      }      const claims = decodeAccessTokenClaims(accessToken);      return {        accessToken,        refreshToken: asString(body.refresh_token),        expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }),        email:          readUserEmail(body) ?? (typeof claims?.email === "string" ? claims.email : undefined),        organizationId:          asString(body.organization_id) ??          (typeof claims?.org_id === "string" ? claims.org_id : undefined),      };    }     const error = asString(body.error);    if (error === "authorization_pending") continue;    if (error === "slow_down") {      intervalMs += 5000;      continue;    }    if (error === "access_denied") {      throw new DeviceLoginError("Login was denied.");    }    if (error === "expired_token") {      throw new DeviceLoginError("The login request expired before it was approved.");    }    throw new DeviceLoginError(      `Login failed: ${asString(body.error_description) ?? error ?? `HTTP ${response.status}`}`,    );  }}; /** Exchange a refresh token for a fresh access token (silent re-auth). Only * providers that issue refresh tokens reach here (WorkOS, which is form-encoded * per RFC 8628); Better Auth's device flow issues no refresh token. */export const refreshDeviceTokens = async (input: {  readonly tokenEndpoint: string;  readonly clientId: string;  readonly refreshToken: string;  readonly headers?: Readonly<Record<string, string>>;  readonly serverOrigin?: string;}): Promise<DeviceTokens> => {  const response = await post(    input.tokenEndpoint,    {      grant_type: "refresh_token",      refresh_token: input.refreshToken,      client_id: input.clientId,    },    "form",    input,  );  const body = await readJson(response);  if (!response.ok) {    throw new DeviceLoginError(      `Token refresh failed: ${asString(body.error_description) ?? asString(body.error) ?? `HTTP ${response.status}`}`,    );  }  const accessToken = asString(body.access_token);  if (!accessToken) throw new DeviceLoginError("Refresh response was missing an access token.");  return {    accessToken,    refreshToken: asString(body.refresh_token) ?? input.refreshToken,    expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }),  };}; export type BrowserOpenCommand = readonly [command: string, args: ReadonlyArray<string>]; const normalizeBrowserOpenUrl = (url: string): string | undefined => {  try {    const parsed = new URL(url);    return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : undefined;  } catch {    return undefined;  }}; export const browserOpenCommand = (  url: string,  platform: typeof process.platform = process.platform,): BrowserOpenCommand | undefined => {  const normalizedUrl = normalizeBrowserOpenUrl(url);  if (!normalizedUrl) return undefined;  if (platform === "darwin") return ["open", [normalizedUrl]];  if (platform === "win32") return ["rundll32.exe", ["url.dll,FileProtocolHandler", normalizedUrl]];  return ["xdg-open", [normalizedUrl]];}; /** Best-effort open the verification URL in the user's default browser. */export const openBrowser = (url: string): void => {  const openCommand = browserOpenCommand(url);  if (!openCommand) return;  const [command, args] = openCommand;  try {    const child = spawn(command, args, { stdio: "ignore", detached: true });    // Swallow async spawn failures (e.g. the opener binary is missing), the    // URL is printed too, so the user can open it by hand.    (child as { on?: (event: "error", listener: () => void) => void }).on?.("error", () => {});    child.unref();  } catch {    // Ignore, the URL is also printed for manual opening.  }};