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
| @@ -1,15 +1,33 @@ | ||
| 1 | 1 | { |
| 2 | "name": "grove-cli", | |
| 2 | "name": "grove-scm", | |
| 3 | 3 | "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", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "bin": { |
| 7 | 7 | "grove": "dist/cli.js" |
| 8 | 8 | }, |
| 9 | "files": [ | |
| 10 | "dist" | |
| 11 | ], | |
| 9 | 12 | "scripts": { |
| 10 | 13 | "build": "tsc", |
| 11 | "dev": "tsx src/cli.ts" | |
| 14 | "dev": "tsx src/cli.ts", | |
| 15 | "prepublishOnly": "npm run build" | |
| 12 | 16 | }, |
| 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", | |
| 13 | 31 | "dependencies": { |
| 14 | 32 | "@clack/prompts": "^1.0.1", |
| 15 | 33 | "open": "^10.1.0" |
| 16 | 34 | |
| @@ -2,6 +2,45 @@ | ||
| 2 | 2 | import { waitForAuthCallback } from "../auth-server.js"; |
| 3 | 3 | import { loadConfig, saveConfig } from "../config.js"; |
| 4 | 4 | |
| 5 | function 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 | ||
| 10 | async 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 | ||
| 5 | 44 | export async function authLogin(args: string[]) { |
| 6 | 45 | const config = await loadConfig(); |
| 7 | 46 | |
| @@ -13,6 +52,21 @@ | ||
| 13 | 52 | |
| 14 | 53 | intro("grove auth login"); |
| 15 | 54 | |
| 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 | |
| 16 | 70 | const { port, result } = await waitForAuthCallback(); |
| 17 | 71 | |
| 18 | 72 | const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`); |
| 19 | 73 | |
| @@ -256,6 +256,108 @@ | ||
| 256 | 256 | } |
| 257 | 257 | }); |
| 258 | 258 | |
| 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 | ||
| 259 | 361 | // ── Get current user ───────────────────────────────────────────── |
| 260 | 362 | |
| 261 | 363 | app.get("/me", { |
| 262 | 364 | |
| @@ -7,15 +7,170 @@ | ||
| 7 | 7 | import { auth } from "@/lib/api"; |
| 8 | 8 | import { Skeleton } from "@/app/components/skeleton"; |
| 9 | 9 | |
| 10 | function 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'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 | ||
| 10 | 159 | function CliAuthInner() { |
| 11 | 160 | const searchParams = useSearchParams(); |
| 12 | 161 | const callback = searchParams.get("callback"); |
| 162 | const code = searchParams.get("code"); | |
| 13 | 163 | const [status, setStatus] = useState< |
| 14 | 164 | "ready" | "confirm" | "loading" | "success" | "error" |
| 15 | 165 | >("ready"); |
| 16 | 166 | const [error, setError] = useState(""); |
| 17 | 167 | const [username, setUsername] = useState<string | null>(null); |
| 18 | 168 | |
| 169 | // Device code flow | |
| 170 | if (code) { | |
| 171 | return <DeviceCodeAuth code={code} />; | |
| 172 | } | |
| 173 | ||
| 19 | 174 | // Validate callback URL is localhost |
| 20 | 175 | const isValidCallback = (() => { |
| 21 | 176 | if (!callback) return false; |
| 22 | 177 | |