cli/src/commands/auth-login.tsblame
View source
59e66671import { intro, outro, spinner, log } from "@clack/prompts";
fafa2602import { mkdirSync, writeFileSync, existsSync } from "node:fs";
fafa2603import { join } from "node:path";
fafa2604import { homedir } from "node:os";
e93a9785import { waitForAuthCallback } from "../auth-server.js";
e93a9786import { loadConfig, saveConfig } from "../config.js";
e93a9787
7010ba98function isHeadless(): boolean {
7010ba99 // SSH session, no display, or explicitly requested
7010ba910 return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION);
7010ba911}
7010ba912
7010ba913async function deviceCodeFlow(config: { hub: string; token?: string }) {
7010ba914 // Request a device code from the hub
7010ba915 const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" });
7010ba916 if (!res.ok) {
7010ba917 throw new Error(`Failed to start device code flow: ${res.statusText}`);
7010ba918 }
7010ba919 const { code, url } = await res.json() as { code: string; url: string; expires_in: number };
7010ba920
7010ba921 log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`);
7010ba922
7010ba923 const s = spinner();
7010ba924 s.start("Waiting for approval");
7010ba925
7010ba926 // Poll for approval
7010ba927 for (let i = 0; i < 120; i++) {
7010ba928 await new Promise((r) => setTimeout(r, 5000));
7010ba929
7010ba930 const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`);
7010ba931 if (!pollRes.ok) {
7010ba932 s.stop("Authentication failed");
7010ba933 throw new Error("Code expired or invalid");
7010ba934 }
7010ba935
7010ba936 const data = await pollRes.json() as { status: string; token?: string };
7010ba937 if (data.status === "complete" && data.token) {
7010ba938 s.stop("Authentication received");
7010ba939 return data.token;
7010ba940 }
7010ba941 }
7010ba942
7010ba943 s.stop("Authentication timed out");
7010ba944 throw new Error("Authentication timed out (10 minutes)");
7010ba945}
7010ba946
fafa26047async function provisionTlsCerts(hub: string, token: string) {
fafa26048 const s = spinner();
fafa26049 s.start("Downloading TLS certificates");
fafa26050 try {
fafa26051 const res = await fetch(`${hub}/api/auth/tls-certs`, {
fafa26052 headers: { Authorization: `Bearer ${token}` },
fafa26053 });
fafa26054 if (!res.ok) {
fafa26055 s.stop("TLS certificates not available (non-fatal)");
fafa26056 return;
fafa26057 }
fafa26058 const { ca, cert, key } = await res.json() as { ca: string; cert: string; key: string };
fafa26059 const tlsDir = join(homedir(), ".grove", "tls");
fafa26060 mkdirSync(tlsDir, { recursive: true });
fafa26061 writeFileSync(join(tlsDir, "ca.crt"), ca);
fafa26062 writeFileSync(join(tlsDir, "client.crt"), cert);
fafa26063 writeFileSync(join(tlsDir, "client.key"), key, { mode: 0o600 });
fafa26064 s.stop("TLS certificates saved");
fafa26065 } catch {
fafa26066 s.stop("TLS certificates not available (non-fatal)");
fafa26067 }
fafa26068}
fafa26069
e93a97870export async function authLogin(args: string[]) {
e93a97871 const config = await loadConfig();
e93a97872
e93a97873 // Allow --hub flag to override hub URL
e93a97874 const hubIdx = args.indexOf("--hub");
e93a97875 if (hubIdx !== -1 && args[hubIdx + 1]) {
e93a97876 config.hub = args[hubIdx + 1];
e93a97877 }
e93a97878
59e666779 intro("grove auth login");
e93a97880
7010ba981 if (isHeadless()) {
7010ba982 // Device code flow for remote/headless environments
7010ba983 try {
7010ba984 const token = await deviceCodeFlow(config);
7010ba985 config.token = token;
7010ba986 await saveConfig(config);
fafa26087 await provisionTlsCerts(config.hub, token);
7010ba988 outro("Authenticated! Token saved to ~/.grove/config.json");
7010ba989 } catch (err: any) {
7010ba990 log.error(err.message);
7010ba991 process.exit(1);
7010ba992 }
7010ba993 return;
7010ba994 }
7010ba995
7010ba996 // Browser-based flow for local environments
ff4354597 const { port, result } = await waitForAuthCallback();
e93a97898
e93a97899 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
e93a978100 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
e93a978101
59e6667102 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
e93a978103
e93a978104 const open = (await import("open")).default;
e93a978105 await open(authUrl);
e93a978106
59e6667107 const s = spinner();
59e6667108 s.start("Waiting for authentication in browser");
e93a978109
e93a978110 try {
e93a978111 const { token } = await result;
59e6667112 s.stop("Authentication received");
e93a978113 config.token = token;
e93a978114 await saveConfig(config);
fafa260115 await provisionTlsCerts(config.hub, token);
59e6667116 outro("Authenticated! Token saved to ~/.grove/config.json");
e93a978117 } catch (err: any) {
59e6667118 s.stop("Authentication failed");
59e6667119 log.error(err.message);
e93a978120 process.exit(1);
e93a978121 }
e93a978122}