| 1 | import { intro, outro, spinner, log } from "@clack/prompts"; |
| 2 | import { mkdirSync, writeFileSync, existsSync } from "node:fs"; |
| 3 | import { join } from "node:path"; |
| 4 | import { homedir } from "node:os"; |
| 5 | import { waitForAuthCallback } from "../auth-server.js"; |
| 6 | import { loadConfig, saveConfig } from "../config.js"; |
| 7 | |
| 8 | function isHeadless(): boolean { |
| 9 | // SSH session, no display, or explicitly requested |
| 10 | return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION); |
| 11 | } |
| 12 | |
| 13 | async function deviceCodeFlow(config: { hub: string; token?: string }) { |
| 14 | // Request a device code from the hub |
| 15 | const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" }); |
| 16 | if (!res.ok) { |
| 17 | throw new Error(`Failed to start device code flow: ${res.statusText}`); |
| 18 | } |
| 19 | const { code, url } = await res.json() as { code: string; url: string; expires_in: number }; |
| 20 | |
| 21 | log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`); |
| 22 | |
| 23 | const s = spinner(); |
| 24 | s.start("Waiting for approval"); |
| 25 | |
| 26 | // Poll for approval |
| 27 | for (let i = 0; i < 120; i++) { |
| 28 | await new Promise((r) => setTimeout(r, 5000)); |
| 29 | |
| 30 | const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`); |
| 31 | if (!pollRes.ok) { |
| 32 | s.stop("Authentication failed"); |
| 33 | throw new Error("Code expired or invalid"); |
| 34 | } |
| 35 | |
| 36 | const data = await pollRes.json() as { status: string; token?: string }; |
| 37 | if (data.status === "complete" && data.token) { |
| 38 | s.stop("Authentication received"); |
| 39 | return data.token; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | s.stop("Authentication timed out"); |
| 44 | throw new Error("Authentication timed out (10 minutes)"); |
| 45 | } |
| 46 | |
| 47 | async function provisionTlsCerts(hub: string, token: string) { |
| 48 | const s = spinner(); |
| 49 | s.start("Downloading TLS certificates"); |
| 50 | try { |
| 51 | const res = await fetch(`${hub}/api/auth/tls-certs`, { |
| 52 | headers: { Authorization: `Bearer ${token}` }, |
| 53 | }); |
| 54 | if (!res.ok) { |
| 55 | s.stop("TLS certificates not available (non-fatal)"); |
| 56 | return; |
| 57 | } |
| 58 | const { ca, cert, key } = await res.json() as { ca: string; cert: string; key: string }; |
| 59 | const tlsDir = join(homedir(), ".grove", "tls"); |
| 60 | mkdirSync(tlsDir, { recursive: true }); |
| 61 | writeFileSync(join(tlsDir, "ca.crt"), ca); |
| 62 | writeFileSync(join(tlsDir, "client.crt"), cert); |
| 63 | writeFileSync(join(tlsDir, "client.key"), key, { mode: 0o600 }); |
| 64 | s.stop("TLS certificates saved"); |
| 65 | } catch { |
| 66 | s.stop("TLS certificates not available (non-fatal)"); |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | export async function authLogin(args: string[]) { |
| 71 | const config = await loadConfig(); |
| 72 | |
| 73 | // Allow --hub flag to override hub URL |
| 74 | const hubIdx = args.indexOf("--hub"); |
| 75 | if (hubIdx !== -1 && args[hubIdx + 1]) { |
| 76 | config.hub = args[hubIdx + 1]; |
| 77 | } |
| 78 | |
| 79 | intro("grove auth login"); |
| 80 | |
| 81 | if (isHeadless()) { |
| 82 | // Device code flow for remote/headless environments |
| 83 | try { |
| 84 | const token = await deviceCodeFlow(config); |
| 85 | config.token = token; |
| 86 | await saveConfig(config); |
| 87 | await provisionTlsCerts(config.hub, token); |
| 88 | outro("Authenticated! Token saved to ~/.grove/config.json"); |
| 89 | } catch (err: any) { |
| 90 | log.error(err.message); |
| 91 | process.exit(1); |
| 92 | } |
| 93 | return; |
| 94 | } |
| 95 | |
| 96 | // Browser-based flow for local environments |
| 97 | const { port, result } = await waitForAuthCallback(); |
| 98 | |
| 99 | const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`); |
| 100 | const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`; |
| 101 | |
| 102 | log.info(`If the browser doesn't open, visit:\n${authUrl}`); |
| 103 | |
| 104 | const open = (await import("open")).default; |
| 105 | await open(authUrl); |
| 106 | |
| 107 | const s = spinner(); |
| 108 | s.start("Waiting for authentication in browser"); |
| 109 | |
| 110 | try { |
| 111 | const { token } = await result; |
| 112 | s.stop("Authentication received"); |
| 113 | config.token = token; |
| 114 | await saveConfig(config); |
| 115 | await provisionTlsCerts(config.hub, token); |
| 116 | outro("Authenticated! Token saved to ~/.grove/config.json"); |
| 117 | } catch (err: any) { |
| 118 | s.stop("Authentication failed"); |
| 119 | log.error(err.message); |
| 120 | process.exit(1); |
| 121 | } |
| 122 | } |
| 123 | |