login.ts 3.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. /**
  2. * Managed-login device flow for `codegraph login`.
  3. *
  4. * Opens the user's browser to the CodeGraph dashboard, where they authorize with
  5. * their account; the CLI meanwhile polls for the minted, org-scoped token and
  6. * stores it (see ./credentials + ./config) to turn on managed reasoning.
  7. *
  8. * This talks to the DASHBOARD (app.getcodegraph.com), not the metered gateway —
  9. * it's a plain OAuth-style device handshake (RFC 8628 shape), nothing proprietary.
  10. * The resulting token is what authenticates the managed reasoning calls (./reasoner).
  11. */
  12. import { spawn } from 'child_process';
  13. const DEFAULT_BASE = 'https://app.getcodegraph.com';
  14. /** Dashboard base for the device-login endpoints; override for testing via CODEGRAPH_LOGIN_URL. */
  15. export function loginBaseUrl(): string {
  16. const raw = process.env.CODEGRAPH_LOGIN_URL?.trim() || DEFAULT_BASE;
  17. return raw.replace(/\/+$/, '');
  18. }
  19. /** The dashboard's response to a device-authorization start request. */
  20. export interface DeviceStart {
  21. device_code: string;
  22. user_code: string;
  23. verification_uri: string;
  24. /** Same URL with the code prefilled, for one-click open. */
  25. verification_uri_complete?: string;
  26. /** Seconds the CLI should wait between polls. */
  27. interval?: number;
  28. /** Seconds until the request expires. */
  29. expires_in?: number;
  30. }
  31. /** Begin a device-authorization request. */
  32. export async function startDeviceLogin(): Promise<DeviceStart> {
  33. const base = loginBaseUrl();
  34. const res = await fetch(`${base}/api/cli/device/start`, {
  35. method: 'POST',
  36. headers: { 'content-type': 'application/json' },
  37. body: '{}',
  38. }).catch(() => null);
  39. if (!res) throw new Error(`couldn't reach ${base} — check your connection`);
  40. if (!res.ok) throw new Error(`couldn't start login (HTTP ${res.status})`);
  41. const j = (await res.json().catch(() => null)) as DeviceStart | null;
  42. if (!j?.device_code || !j.user_code) throw new Error('login start returned an unexpected response');
  43. return j;
  44. }
  45. /** Poll until the user approves in the browser; resolves with the org token. */
  46. export async function pollForToken(deviceCode: string, intervalSec: number, expiresInSec: number): Promise<string> {
  47. const deadline = Date.now() + Math.max(30, expiresInSec || 600) * 1000;
  48. let waitMs = Math.max(2, intervalSec || 5) * 1000;
  49. const base = loginBaseUrl();
  50. while (Date.now() < deadline) {
  51. await new Promise((r) => setTimeout(r, waitMs));
  52. const res = await fetch(`${base}/api/cli/device/token`, {
  53. method: 'POST',
  54. headers: { 'content-type': 'application/json' },
  55. body: JSON.stringify({ device_code: deviceCode }),
  56. }).catch(() => null);
  57. if (!res) continue; // transient network blip — keep polling until the deadline
  58. if (res.status === 200) {
  59. const j = (await res.json().catch(() => null)) as { token?: string } | null;
  60. if (j?.token) return j.token;
  61. } else if (res.status === 429) {
  62. waitMs += 2000; // server asked us to slow down
  63. } else if (res.status === 404 || res.status === 410) {
  64. throw new Error('the login request expired — run `codegraph login` again');
  65. }
  66. // 202 (authorization pending) → keep waiting
  67. }
  68. throw new Error('login timed out before you approved — run `codegraph login` again');
  69. }
  70. /** Best-effort: open a URL in the default browser. Never throws — the URL is also printed. */
  71. export async function openBrowser(url: string): Promise<void> {
  72. const [cmd, args] =
  73. process.platform === 'darwin' ? ['open', [url]]
  74. : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
  75. : ['xdg-open', [url]];
  76. try {
  77. const child = spawn(cmd as string, args as string[], { stdio: 'ignore', detached: true });
  78. child.on('error', () => {});
  79. child.unref();
  80. } catch {
  81. /* the URL is printed for manual open */
  82. }
  83. }