Device code auth flow for headless CLI login

Adds a device code flow so 'grove auth login' works over SSH/remote.
CLI detects SSH session, requests a code from hub API, prints URL + code.
User visits URL on any device, approves, CLI polls and gets the token.

- Hub API: POST/GET /auth/device-code, POST /auth/device-code/:code/approve
- CLI: auto-detects headless env, falls back to device code flow
- Web: cli-auth page handles ?code= param for device code approval
Anton Kaminsky22d ago7010ba911dadparent d6f1620
4 files changed+332-3
cli/package.json
@@ -1,15 +1,33 @@
11{
2 "name": "grove-cli",
2 "name": "grove-scm",
33 "version": "0.1.0",
4 "description": "CLI for Grove — manage repos, instances, and auth",
4 "description": "CLI for Grove — self-hosted source control built on Sapling and Mononoke",
55 "type": "module",
66 "bin": {
77 "grove": "dist/cli.js"
88 },
9 "files": [
10 "dist"
11 ],
912 "scripts": {
1013 "build": "tsc",
11 "dev": "tsx src/cli.ts"
14 "dev": "tsx src/cli.ts",
15 "prepublishOnly": "npm run build"
1216 },
17 "keywords": [
18 "grove",
19 "sapling",
20 "mononoke",
21 "scm",
22 "source-control",
23 "self-hosted"
24 ],
25 "license": "MIT",
26 "repository": {
27 "type": "git",
28 "url": "https://grove.host/letterpress-labs/grove"
29 },
30 "homepage": "https://grove.host",
1331 "dependencies": {
1432 "@clack/prompts": "^1.0.1",
1533 "open": "^10.1.0"
1634
cli/src/commands/auth-login.ts
@@ -2,6 +2,45 @@
22import { waitForAuthCallback } from "../auth-server.js";
33import { loadConfig, saveConfig } from "../config.js";
44
5function isHeadless(): boolean {
6 // SSH session, no display, or explicitly requested
7 return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION);
8}
9
10async function deviceCodeFlow(config: { hub: string; token?: string }) {
11 // Request a device code from the hub
12 const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" });
13 if (!res.ok) {
14 throw new Error(`Failed to start device code flow: ${res.statusText}`);
15 }
16 const { code, url } = await res.json() as { code: string; url: string; expires_in: number };
17
18 log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`);
19
20 const s = spinner();
21 s.start("Waiting for approval");
22
23 // Poll for approval
24 for (let i = 0; i < 120; i++) {
25 await new Promise((r) => setTimeout(r, 5000));
26
27 const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`);
28 if (!pollRes.ok) {
29 s.stop("Authentication failed");
30 throw new Error("Code expired or invalid");
31 }
32
33 const data = await pollRes.json() as { status: string; token?: string };
34 if (data.status === "complete" && data.token) {
35 s.stop("Authentication received");
36 return data.token;
37 }
38 }
39
40 s.stop("Authentication timed out");
41 throw new Error("Authentication timed out (10 minutes)");
42}
43
544export async function authLogin(args: string[]) {
645 const config = await loadConfig();
746
@@ -13,6 +52,21 @@
1352
1453 intro("grove auth login");
1554
55 if (isHeadless()) {
56 // Device code flow for remote/headless environments
57 try {
58 const token = await deviceCodeFlow(config);
59 config.token = token;
60 await saveConfig(config);
61 outro("Authenticated! Token saved to ~/.grove/config.json");
62 } catch (err: any) {
63 log.error(err.message);
64 process.exit(1);
65 }
66 return;
67 }
68
69 // Browser-based flow for local environments
1670 const { port, result } = await waitForAuthCallback();
1771
1872 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
1973
hub-api/src/routes/auth.ts
@@ -256,6 +256,108 @@
256256 }
257257 });
258258
259 // ── Device Code Flow (for headless/remote CLI auth) ──────────────
260
261 const deviceCodes = new Map<
262 string,
263 { expiresAt: number; token?: string; status: "pending" | "complete" }
264 >();
265
266 // Cleanup expired device codes
267 setInterval(() => {
268 const now = Date.now();
269 for (const [key, val] of deviceCodes) {
270 if (val.expiresAt < now) deviceCodes.delete(key);
271 }
272 }, 60 * 1000);
273
274 function generateCode(): string {
275 const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
276 let code = "";
277 for (let i = 0; i < 8; i++) {
278 if (i === 4) code += "-";
279 code += chars[Math.floor(Math.random() * chars.length)];
280 }
281 return code;
282 }
283
284 // CLI calls this to start device code flow
285 app.post("/device-code", async () => {
286 const code = generateCode();
287 deviceCodes.set(code, {
288 expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
289 status: "pending",
290 });
291 const origin = ORIGIN_ENV.split(",")[0].trim();
292 return { code, url: `${origin}/cli-auth?code=${code}`, expires_in: 600 };
293 });
294
295 // CLI polls this to check if user approved
296 app.get("/device-code/:code", async (request, reply) => {
297 const { code } = request.params as { code: string };
298 const entry = deviceCodes.get(code);
299
300 if (!entry || entry.expiresAt < Date.now()) {
301 return reply.code(404).send({ error: "Code not found or expired" });
302 }
303
304 if (entry.status === "complete" && entry.token) {
305 deviceCodes.delete(code);
306 return { status: "complete", token: entry.token };
307 }
308
309 return { status: "pending" };
310 });
311
312 // Web page calls this (authenticated) to approve the device code
313 app.post("/device-code/:code/approve", {
314 preHandler: [(app as any).authenticate],
315 handler: async (request, reply) => {
316 const { code } = request.params as { code: string };
317 const entry = deviceCodes.get(code);
318
319 if (!entry || entry.expiresAt < Date.now()) {
320 return reply.code(404).send({ error: "Code not found or expired" });
321 }
322
323 if (entry.status === "complete") {
324 return reply.code(400).send({ error: "Code already used" });
325 }
326
327 const payload = request.user as any;
328 const user = db
329 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
330 .get(payload.id) as any;
331
332 if (!user) {
333 return reply.code(404).send({ error: "User not found" });
334 }
335
336 // Create a PAT
337 const token = app.jwt.sign(
338 {
339 id: user.id,
340 username: user.username,
341 display_name: user.display_name,
342 type: "pat",
343 },
344 { expiresIn: "365d" }
345 );
346
347 const tokenHash = createHash("sha256").update(token).digest("hex");
348 const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
349
350 db.prepare(
351 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
352 ).run(user.id, `CLI (${new Date().toLocaleDateString()})`, tokenHash, expiresAt);
353
354 entry.status = "complete";
355 entry.token = token;
356
357 return { status: "approved" };
358 },
359 });
360
259361 // ── Get current user ─────────────────────────────────────────────
260362
261363 app.get("/me", {
262364
web/app/cli-auth/page.tsx
@@ -7,15 +7,170 @@
77import { auth } from "@/lib/api";
88import { Skeleton } from "@/app/components/skeleton";
99
10function DeviceCodeAuth({ code }: { code: string }) {
11 const [status, setStatus] = useState<"ready" | "confirm" | "loading" | "success" | "error">("ready");
12 const [error, setError] = useState("");
13 const [username, setUsername] = useState<string | null>(null);
14
15 useEffect(() => {
16 const token = localStorage.getItem("grove_hub_token");
17 if (token) {
18 try {
19 const payload = JSON.parse(atob(token.split(".")[1]));
20 setUsername(payload.username);
21 } catch { /* ignore */ }
22 setStatus("confirm");
23 }
24 }, []);
25
26 async function approveWithToken(sessionToken: string) {
27 setStatus("loading");
28 try {
29 const res = await fetch(`/api/hub/auth/device-code/${code}/approve`, {
30 method: "POST",
31 headers: {
32 "Content-Type": "application/json",
33 Authorization: `Bearer ${sessionToken}`,
34 },
35 });
36 if (!res.ok) {
37 const data = await res.json().catch(() => ({}));
38 throw new Error((data as any).error || "Failed to approve");
39 }
40 setStatus("success");
41 } catch (err: any) {
42 setStatus("error");
43 setError(err.message || "Failed to approve device code");
44 }
45 }
46
47 async function handleAuthorize() {
48 const token = localStorage.getItem("grove_hub_token");
49 if (token) await approveWithToken(token);
50 }
51
52 async function handleLogin() {
53 setError("");
54 setStatus("loading");
55 try {
56 const { options } = await auth.loginBegin();
57 const assertion = await startAuthentication({ optionsJSON: options });
58 const result = await auth.loginComplete({
59 response: assertion,
60 challenge: options.challenge,
61 });
62 await approveWithToken(result.token);
63 } catch (err: any) {
64 setStatus("error");
65 if (err.name === "NotAllowedError") {
66 setError("Passkey authentication was cancelled.");
67 } else if (err.message === "Unknown credential") {
68 setError("Passkey not recognized. Do you have an account?");
69 } else {
70 setError(err.message || "Authentication failed");
71 }
72 }
73 }
74
75 return (
76 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
77 <div
78 className="w-full max-w-sm p-8"
79 style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-subtle)" }}
80 >
81 <div className="flex justify-center mb-6">
82 <GroveLogo size={40} />
83 </div>
84 <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1>
85
86 {status === "success" ? (
87 <div className="text-center mt-6">
88 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
89 CLI authorized! You can close this tab and return to the terminal.
90 </p>
91 </div>
92 ) : status === "confirm" ? (
93 <>
94 <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}>
95 Authorize the CLI as{" "}
96 <strong style={{ color: "var(--text-primary)" }}>{username}</strong>?
97 </p>
98 <div
99 className="text-center text-lg font-mono tracking-widest mb-6 py-3"
100 style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }}
101 >
102 {code}
103 </div>
104 {error && (
105 <div
106 className="text-sm px-3 py-2 mb-4"
107 style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }}
108 >
109 {error}
110 </div>
111 )}
112 <button
113 onClick={handleAuthorize}
114 className="w-full text-sm py-2"
115 style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
116 >
117 Authorize CLI
118 </button>
119 </>
120 ) : (
121 <>
122 <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}>
123 Confirm the code matches what&apos;s shown in your terminal.
124 </p>
125 <div
126 className="text-center text-lg font-mono tracking-widest mb-6 py-3"
127 style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }}
128 >
129 {code}
130 </div>
131 {error && (
132 <div
133 className="text-sm px-3 py-2 mb-4"
134 style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }}
135 >
136 {error}
137 </div>
138 )}
139 <button
140 onClick={handleLogin}
141 disabled={status === "loading"}
142 className="w-full text-sm py-2"
143 style={{
144 backgroundColor: "var(--accent)",
145 color: "var(--accent-text)",
146 opacity: status === "loading" ? 0.6 : 1,
147 cursor: status === "loading" ? "wait" : "pointer",
148 }}
149 >
150 {status === "loading" ? "Authenticating..." : "Sign in with Passkey"}
151 </button>
152 </>
153 )}
154 </div>
155 </div>
156 );
157}
158
10159function CliAuthInner() {
11160 const searchParams = useSearchParams();
12161 const callback = searchParams.get("callback");
162 const code = searchParams.get("code");
13163 const [status, setStatus] = useState<
14164 "ready" | "confirm" | "loading" | "success" | "error"
15165 >("ready");
16166 const [error, setError] = useState("");
17167 const [username, setUsername] = useState<string | null>(null);
18168
169 // Device code flow
170 if (code) {
171 return <DeviceCodeAuth code={code} />;
172 }
173
19174 // Validate callback URL is localhost
20175 const isValidCallback = (() => {
21176 if (!callback) return false;
22177