diff --git a/cli/package.json b/cli/package.json index 4968e023..439a9f7f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.6.4", + "version": "0.6.5", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/do-oauth.test.ts b/cli/src/__tests__/do-oauth.test.ts new file mode 100644 index 00000000..159d7271 --- /dev/null +++ b/cli/src/__tests__/do-oauth.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +/** + * Tests for DigitalOcean OAuth flow in cli/src/digitalocean/digitalocean.ts. + * + * Covers: + * - Config persistence (save/load with refresh_token, expires_at) + * - CSRF state generation + * - OAuth code validation + * - Token expiry detection + * - ensureDoToken() flow ordering + */ + +let testDir: string; +let origHome: string | undefined; + +beforeEach(() => { + testDir = join( + tmpdir(), + `spawn-do-oauth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, ".config", "spawn"), { recursive: true }); + origHome = process.env.HOME; + process.env.HOME = testDir; +}); + +afterEach(() => { + process.env.HOME = origHome; + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } +}); + +// ── Config Persistence ──────────────────────────────────────────────────────── + +describe("DO config persistence", () => { + const configPath = () => join(testDir, ".config", "spawn", "digitalocean.json"); + + it("should save and load a basic token", () => { + const config = { + api_key: "dop_v1_test123", + token: "dop_v1_test123", + }; + writeFileSync(configPath(), JSON.stringify(config)); + + const data = JSON.parse(readFileSync(configPath(), "utf-8")); + expect(data.api_key).toBe("dop_v1_test123"); + expect(data.token).toBe("dop_v1_test123"); + }); + + it("should save oauth tokens with refresh_token and expires_at", () => { + const config = { + api_key: "access-token-abc", + token: "access-token-abc", + refresh_token: "refresh-token-xyz", + expires_at: Math.floor(Date.now() / 1000) + 2592000, + auth_method: "oauth", + }; + writeFileSync(configPath(), JSON.stringify(config, null, 2)); + + const data = JSON.parse(readFileSync(configPath(), "utf-8")); + expect(data.api_key).toBe("access-token-abc"); + expect(data.refresh_token).toBe("refresh-token-xyz"); + expect(data.auth_method).toBe("oauth"); + expect(data.expires_at).toBeGreaterThan(Date.now() / 1000); + }); + + it("should handle missing config file gracefully", () => { + // Config file doesn't exist yet + expect(existsSync(configPath())).toBe(false); + // Reading should not throw + let data = null; + try { + data = JSON.parse(readFileSync(configPath(), "utf-8")); + } catch { + // expected + } + expect(data).toBeNull(); + }); + + it("should handle malformed JSON gracefully", () => { + writeFileSync(configPath(), "not valid json {{{"); + let data = null; + try { + data = JSON.parse(readFileSync(configPath(), "utf-8")); + } catch { + // expected + } + expect(data).toBeNull(); + }); +}); + +// ── Token Validation Regex ────────────────────────────────────────────────── + +describe("DO token format validation", () => { + const tokenRegex = /^[a-zA-Z0-9._/@:+=, -]+$/; + + it("should accept valid DO API tokens", () => { + expect(tokenRegex.test("dop_v1_abc123def456")).toBe(true); + expect(tokenRegex.test("dop_v1_abcdefghijklmnop1234567890abcdef1234567890")).toBe(true); + }); + + it("should accept tokens with common safe characters", () => { + expect(tokenRegex.test("token.with.dots")).toBe(true); + expect(tokenRegex.test("token-with-dashes")).toBe(true); + expect(tokenRegex.test("token_with_underscores")).toBe(true); + }); + + it("should reject tokens with dangerous characters", () => { + expect(tokenRegex.test("token;rm -rf /")).toBe(false); + expect(tokenRegex.test("token\ninjected")).toBe(false); + expect(tokenRegex.test("token$(cmd)")).toBe(false); + expect(tokenRegex.test("token`cmd`")).toBe(false); + }); + + it("should reject empty string", () => { + expect(tokenRegex.test("")).toBe(false); + }); +}); + +// ── CSRF State Generation ─────────────────────────────────────────────────── + +describe("CSRF state generation", () => { + function generateCsrfState(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + } + + it("should generate 32 hex characters (128 bits)", () => { + const state = generateCsrfState(); + expect(state).toHaveLength(32); + expect(state).toMatch(/^[0-9a-f]{32}$/); + }); + + it("should generate unique values", () => { + const states = new Set(); + for (let i = 0; i < 100; i++) { + states.add(generateCsrfState()); + } + expect(states.size).toBe(100); + }); + + it("should be URL-safe", () => { + const state = generateCsrfState(); + expect(state).not.toContain(" "); + expect(state).not.toContain("&"); + expect(state).not.toContain("?"); + expect(state).not.toContain("/"); + expect(state).not.toContain("#"); + }); +}); + +// ── OAuth Code Validation ─────────────────────────────────────────────────── + +describe("OAuth code validation", () => { + // Matches the regex used in the OAuth callback handler + const codeRegex = /^[a-zA-Z0-9_-]{8,256}$/; + + it("should accept valid authorization codes", () => { + expect(codeRegex.test("abc123def456")).toBe(true); + expect(codeRegex.test("a1b2c3d4e5f6g7h8")).toBe(true); + expect(codeRegex.test("code-with-dashes")).toBe(true); + expect(codeRegex.test("code_with_underscores")).toBe(true); + }); + + it("should reject codes shorter than 8 characters", () => { + expect(codeRegex.test("abc")).toBe(false); + expect(codeRegex.test("1234567")).toBe(false); + }); + + it("should reject codes longer than 256 characters", () => { + expect(codeRegex.test("a".repeat(257))).toBe(false); + }); + + it("should accept codes up to 256 characters", () => { + expect(codeRegex.test("a".repeat(256))).toBe(true); + }); + + it("should reject codes with special characters", () => { + expect(codeRegex.test("code;injection")).toBe(false); + expect(codeRegex.test("code`; + const ERROR_HTML = `

Authorization Failed

Invalid or missing state parameter (CSRF protection). Please try again.

`; + + it("should include success message in success HTML", () => { + expect(SUCCESS_HTML).toContain("Authorization Successful"); + }); + + it("should include auto-close script in success HTML", () => { + expect(SUCCESS_HTML).toContain("window.close"); + }); + + it("should include CSRF warning in error HTML", () => { + expect(ERROR_HTML).toContain("CSRF"); + }); + + it("should include DigitalOcean branding in success HTML", () => { + expect(SUCCESS_HTML).toContain("DigitalOcean"); + }); + + it("should include retry guidance in error HTML", () => { + expect(ERROR_HTML).toContain("try again"); + }); +}); + +// ── OAuth Always Enabled ───────────────────────────────────────────────────── + +describe("OAuth is always enabled (hardcoded credentials)", () => { + it("should always be configured with hardcoded client credentials", () => { + // Credentials are hardcoded constants, not env vars — OAuth is always available + const clientId = "c82b64ac5f9cd4d03b686bebf17546c603b9c368a296a8c4c0718b1f405e4bdc"; + const clientSecret = "8083ef0317481d802d15b68f1c0b545b726720dbf52d00d17f649cc794efdfd9"; + expect(clientId).toHaveLength(64); + expect(clientSecret).toHaveLength(64); + expect(!!(clientId && clientSecret)).toBe(true); + }); +}); + +// ── Refresh Token Format ──────────────────────────────────────────────────── + +describe("Refresh token format validation", () => { + const tokenRegex = /^[a-zA-Z0-9._/@:+=, -]+$/; + + it("should accept typical refresh tokens", () => { + expect(tokenRegex.test("refresh_abc123def456")).toBe(true); + expect(tokenRegex.test("rt_v1_abcdef1234567890")).toBe(true); + }); + + it("should reject tokens with injection attempts", () => { + expect(tokenRegex.test("token;echo hacked")).toBe(false); + expect(tokenRegex.test("token\x00null")).toBe(false); + }); +}); + +// ── Config File Extended Format ───────────────────────────────────────────── + +describe("Extended config file format", () => { + it("should round-trip full oauth config", () => { + const config = { + api_key: "access-token-123", + token: "access-token-123", + refresh_token: "refresh-token-456", + expires_at: 1800000000, + auth_method: "oauth" as const, + }; + + const json = JSON.stringify(config, null, 2); + const parsed = JSON.parse(json); + + expect(parsed.api_key).toBe(config.api_key); + expect(parsed.token).toBe(config.token); + expect(parsed.refresh_token).toBe(config.refresh_token); + expect(parsed.expires_at).toBe(config.expires_at); + expect(parsed.auth_method).toBe("oauth"); + }); + + it("should be backward compatible with old config format", () => { + // Old format only has api_key and token + const oldConfig = { + api_key: "old-token-123", + token: "old-token-123", + }; + + const parsed = JSON.parse(JSON.stringify(oldConfig)); + // Should still work — refresh_token will be undefined + expect(parsed.api_key).toBe("old-token-123"); + expect(parsed.refresh_token).toBeUndefined(); + expect(parsed.expires_at).toBeUndefined(); + expect(parsed.auth_method).toBeUndefined(); + }); +}); diff --git a/cli/src/digitalocean/digitalocean.ts b/cli/src/digitalocean/digitalocean.ts index 0a5ae6ae..f9b549c1 100644 --- a/cli/src/digitalocean/digitalocean.ts +++ b/cli/src/digitalocean/digitalocean.ts @@ -7,7 +7,7 @@ import { logError, logStep, prompt, - jsonEscape, + openBrowser, validateServerName, validateRegionName, toKebabCase, @@ -16,6 +16,27 @@ import { const DO_API_BASE = "https://api.digitalocean.com/v2"; const DO_DASHBOARD_URL = "https://cloud.digitalocean.com/droplets"; +// ─── DO OAuth Constants ───────────────────────────────────────────────────── + +const DO_OAUTH_AUTHORIZE = "https://cloud.digitalocean.com/v1/oauth/authorize"; +const DO_OAUTH_TOKEN = "https://cloud.digitalocean.com/v1/oauth/token"; + +// OAuth application credentials (embedded, same pattern as gh CLI / doctl). +// Public clients cannot keep secrets confidential — security comes from the +// authorization code flow itself (user consent, localhost redirect, CSRF state). +const DO_CLIENT_ID = "c82b64ac5f9cd4d03b686bebf17546c603b9c368a296a8c4c0718b1f405e4bdc"; +const DO_CLIENT_SECRET = "8083ef0317481d802d15b68f1c0b545b726720dbf52d00d17f649cc794efdfd9"; + +// Fine-grained scopes for spawn (minimum required) +const DO_SCOPES = [ + "droplet:create", "droplet:delete", "droplet:read", + "ssh_key:create", "ssh_key:read", + "regions:read", "sizes:read", + "image:read", "actions:read", +].join(" "); + +const DO_OAUTH_CALLBACK_PORT = 5190; + // ─── State ─────────────────────────────────────────────────────────────────── let doToken = ""; let doDropletId = ""; @@ -86,27 +107,68 @@ function parseJson(text: string): any { const DO_CONFIG_PATH = `${process.env.HOME}/.config/spawn/digitalocean.json`; -async function saveTokenToConfig(token: string): Promise { +interface DoConfig { + api_key?: string; + token?: string; + refresh_token?: string; + expires_at?: number; + auth_method?: "oauth" | "manual"; +} + +function loadConfig(): DoConfig | null { + try { + return JSON.parse(readFileSync(DO_CONFIG_PATH, "utf-8")); + } catch { + return null; + } +} + +async function saveConfig(config: DoConfig): Promise { const dir = DO_CONFIG_PATH.replace(/\/[^/]+$/, ""); await Bun.spawn(["mkdir", "-p", dir]).exited; - const escaped = jsonEscape(token); await Bun.write( DO_CONFIG_PATH, - `{\n "api_key": ${escaped},\n "token": ${escaped}\n}\n`, + JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }, ); } -function loadTokenFromConfig(): string | null { - try { - const data = JSON.parse(readFileSync(DO_CONFIG_PATH, "utf-8")); - const token = data.api_key || data.token || ""; - if (!token) return null; - if (!/^[a-zA-Z0-9._/@:+=, -]+$/.test(token)) return null; - return token; - } catch { - return null; +async function saveTokenToConfig(token: string, refreshToken?: string, expiresIn?: number): Promise { + const config: DoConfig = { + api_key: token, + token, + }; + if (refreshToken) { + config.refresh_token = refreshToken; + config.auth_method = "oauth"; } + if (expiresIn) { + config.expires_at = Math.floor(Date.now() / 1000) + expiresIn; + } + await saveConfig(config); +} + +function loadTokenFromConfig(): string | null { + const data = loadConfig(); + if (!data) return null; + const token = data.api_key || data.token || ""; + if (!token) return null; + if (!/^[a-zA-Z0-9._/@:+=, -]+$/.test(token)) return null; + return token; +} + +function loadRefreshToken(): string | null { + const data = loadConfig(); + if (!data?.refresh_token) return null; + if (!/^[a-zA-Z0-9._/@:+=, -]+$/.test(data.refresh_token)) return null; + return data.refresh_token; +} + +function isTokenExpired(): boolean { + const data = loadConfig(); + if (!data?.expires_at) return false; + // Consider expired 5 minutes before actual expiry + return Math.floor(Date.now() / 1000) >= data.expires_at - 300; } // ─── Token Validation ──────────────────────────────────────────────────────── @@ -121,6 +183,236 @@ async function testDoToken(): Promise { } } +// ─── DO OAuth Flow ────────────────────────────────────────────────────────── + +const OAUTH_CSS = `*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff;color:#090a0b}@media(prefers-color-scheme:dark){body{background:#090a0b;color:#fafafa}}.card{text-align:center;max-width:400px;padding:2rem}.icon{font-size:2.5rem;margin-bottom:1rem}h1{font-size:1.25rem;font-weight:600;margin-bottom:.5rem}p{font-size:.875rem;color:#6b7280}@media(prefers-color-scheme:dark){p{color:#9ca3af}}`; + +const OAUTH_SUCCESS_HTML = `

DigitalOcean Authorization Successful

You can close this tab and return to your terminal.

`; + +const OAUTH_ERROR_HTML = `

Authorization Failed

Invalid or missing state parameter (CSRF protection). Please try again.

`; + +function generateCsrfState(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function isOAuthConfigured(): boolean { + return true; +} + +async function tryRefreshDoToken(): Promise { + if (!isOAuthConfigured()) return null; + + const refreshToken = loadRefreshToken(); + if (!refreshToken) return null; + + logStep("Attempting to refresh DigitalOcean token..."); + + try { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: DO_CLIENT_ID, + client_secret: DO_CLIENT_SECRET, + }); + + const resp = await fetch(DO_OAUTH_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(30_000), + }); + + if (!resp.ok) { + logWarn("Token refresh failed — refresh token may be expired"); + return null; + } + + const data = await resp.json() as any; + if (!data.access_token) { + logWarn("Token refresh returned no access token"); + return null; + } + + await saveTokenToConfig( + data.access_token, + data.refresh_token || refreshToken, + data.expires_in, + ); + logInfo("DigitalOcean token refreshed successfully"); + return data.access_token; + } catch { + logWarn("Token refresh request failed"); + return null; + } +} + +async function tryDoOAuth(): Promise { + if (!isOAuthConfigured()) { + return null; + } + + logStep("Attempting DigitalOcean OAuth authentication..."); + + // Check connectivity to DigitalOcean + try { + await fetch("https://cloud.digitalocean.com", { + method: "HEAD", + signal: AbortSignal.timeout(5_000), + }); + } catch { + logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable"); + return null; + } + + const csrfState = generateCsrfState(); + let oauthCode: string | null = null; + let server: ReturnType | null = null; + + // Try ports in range + let actualPort = DO_OAUTH_CALLBACK_PORT; + for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) { + try { + server = Bun.serve({ + port: p, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/callback") { + // Check for error response from DO + const error = url.searchParams.get("error"); + if (error) { + const desc = url.searchParams.get("error_description") || error; + logError(`DigitalOcean authorization denied: ${desc}`); + return new Response(OAUTH_ERROR_HTML, { + status: 403, + headers: { "Content-Type": "text/html", Connection: "close" }, + }); + } + + const code = url.searchParams.get("code"); + if (!code) { + return new Response(OAUTH_ERROR_HTML, { + status: 400, + headers: { "Content-Type": "text/html", Connection: "close" }, + }); + } + + // CSRF state validation + if (url.searchParams.get("state") !== csrfState) { + return new Response(OAUTH_ERROR_HTML, { + status: 403, + headers: { "Content-Type": "text/html", Connection: "close" }, + }); + } + + // Validate code format (alphanumeric + common delimiters) + if (!/^[a-zA-Z0-9_-]{8,256}$/.test(code)) { + return new Response( + "

Invalid Authorization Code

", + { status: 400, headers: { "Content-Type": "text/html" } }, + ); + } + + oauthCode = code; + return new Response(OAUTH_SUCCESS_HTML, { + headers: { "Content-Type": "text/html", Connection: "close" }, + }); + } + return new Response("Waiting for DigitalOcean OAuth callback...", { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + actualPort = p; + break; + } catch { + continue; + } + } + + if (!server) { + logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`); + return null; + } + + logInfo(`OAuth server listening on port ${actualPort}`); + + const redirectUri = `http://localhost:${actualPort}/callback`; + const authParams = new URLSearchParams({ + client_id: DO_CLIENT_ID, + redirect_uri: redirectUri, + response_type: "code", + scope: DO_SCOPES, + state: csrfState, + }); + const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`; + + logStep("Opening browser to authorize with DigitalOcean..."); + logStep(`If the browser doesn't open, visit: ${authUrl}`); + openBrowser(authUrl); + + // Wait up to 120 seconds + logStep("Waiting for authorization in browser (timeout: 120s)..."); + const deadline = Date.now() + 120_000; + while (!oauthCode && Date.now() < deadline) { + await sleep(500); + } + + server.stop(true); + + if (!oauthCode) { + logError("OAuth authentication timed out after 120 seconds"); + logError("Alternative: Use a manual API token instead"); + logError(" export DO_API_TOKEN=dop_v1_..."); + return null; + } + + // Exchange code for token + logStep("Exchanging authorization code for access token..."); + try { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCode, + client_id: DO_CLIENT_ID, + client_secret: DO_CLIENT_SECRET, + redirect_uri: redirectUri, + }); + + const resp = await fetch(DO_OAUTH_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(30_000), + }); + + if (!resp.ok) { + const errText = await resp.text(); + logError(`Token exchange failed (HTTP ${resp.status})`); + logWarn(`Response: ${errText.slice(0, 200)}`); + return null; + } + + const data = await resp.json() as any; + if (!data.access_token) { + logError("Token exchange returned no access token"); + return null; + } + + await saveTokenToConfig( + data.access_token, + data.refresh_token, + data.expires_in, + ); + logInfo("Successfully obtained DigitalOcean access token via OAuth!"); + return data.access_token; + } catch (err) { + logError("Failed to exchange authorization code"); + return null; + } +} + // ─── Authentication ────────────────────────────────────────────────────────── export async function ensureDoToken(): Promise { @@ -136,19 +428,52 @@ export async function ensureDoToken(): Promise { doToken = ""; } - // 2. Saved config + // 2. Saved config (check expiry first, try refresh if needed) const saved = loadTokenFromConfig(); if (saved) { - doToken = saved; - if (await testDoToken()) { - logInfo("Using saved DigitalOcean API token"); - return; + if (isTokenExpired()) { + logWarn("Saved DigitalOcean token has expired, trying refresh..."); + const refreshed = await tryRefreshDoToken(); + if (refreshed) { + doToken = refreshed; + if (await testDoToken()) { + logInfo("Using refreshed DigitalOcean token"); + return; + } + } + } else { + doToken = saved; + if (await testDoToken()) { + logInfo("Using saved DigitalOcean API token"); + return; + } + logWarn("Saved DigitalOcean token is invalid or expired"); + // Try refresh as fallback + const refreshed = await tryRefreshDoToken(); + if (refreshed) { + doToken = refreshed; + if (await testDoToken()) { + logInfo("Using refreshed DigitalOcean token"); + return; + } + } } - logWarn("Saved DigitalOcean token is invalid or expired"); doToken = ""; } - // 3. Manual entry + // 3. Try OAuth browser flow + const oauthToken = await tryDoOAuth(); + if (oauthToken) { + doToken = oauthToken; + if (await testDoToken()) { + logInfo("Using DigitalOcean token from OAuth"); + return; + } + logWarn("OAuth token failed validation"); + doToken = ""; + } + + // 4. Manual entry (fallback) logStep("DigitalOcean API Token Required"); logWarn("Get a token from: https://cloud.digitalocean.com/account/api/tokens");