3.9 KB123 lines
Blame
1import { intro, outro, spinner, log } from "@clack/prompts";
2import { mkdirSync, writeFileSync, existsSync } from "node:fs";
3import { join } from "node:path";
4import { homedir } from "node:os";
5import { waitForAuthCallback } from "../auth-server.js";
6import { loadConfig, saveConfig } from "../config.js";
7
8function 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
13async 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
47async 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
70export 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