diff --git a/README.md b/README.md index 0a6b9249..89c486e0 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,20 @@ meridian profile add work > **⚠ Important:** Claude's OAuth reuses your browser session. Before adding a second account, sign out of claude.ai and sign into the other account first. +#### Headless / SSH: complete Claude OAuth with a pasted code + +When you still want a normal Claude Max browser-login profile but the Meridian host cannot open a browser (SSH, WSL, containers, remote servers), use `--headless`. Meridian prints a Claude OAuth URL, prompts for the returned code, exchanges it with PKCE, and saves the resulting credentials into the profile's isolated `CLAUDE_CONFIG_DIR`: + +```bash +meridian profile add work --headless +``` + +Open the printed URL in a browser, sign in to the target Claude account, then paste the returned code at Meridian's `Paste code:` prompt. For an existing browser-login profile: + +```bash +meridian profile login work --headless +``` + #### Headless / CI: register an OAuth token When a browser isn't available (containers, CI runners, remote shells), generate a long-lived OAuth token with `claude setup-token` and register it as a profile: @@ -269,11 +283,11 @@ You can also switch profiles from the web UI at `http://127.0.0.1:3456/profiles` | Command | Description | |---------|-------------| -| `meridian profile add ` | Add a profile and authenticate via browser | +| `meridian profile add [--headless]` | Add a profile and authenticate via Claude OAuth; `--headless` prints a URL, prompts for the returned code, and stores the exchanged credentials | | `meridian profile add --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) | | `meridian profile list` | List profiles and auth status | | `meridian profile switch ` | Switch the active profile (requires running proxy) | -| `meridian profile login ` | Re-authenticate an expired profile (browser-login profiles only) | +| `meridian profile login [--headless]` | Re-authenticate an expired profile (browser-login profiles only); `--headless` uses the URL/code flow | | `meridian profile remove ` | Remove a profile and its credentials | ### How it works @@ -734,11 +748,11 @@ export default { |---------|-------------| | `meridian` | Start the proxy server | | `meridian setup` | Configure the OpenCode plugin in `~/.config/opencode/opencode.json` | -| `meridian profile add ` | Add a profile and authenticate via browser | +| `meridian profile add [--headless]` | Add a profile and authenticate via Claude OAuth; `--headless` prints a URL, prompts for the returned code, and stores the exchanged credentials | | `meridian profile add --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) | | `meridian profile list` | List all profiles and their auth status | | `meridian profile switch ` | Switch the active profile (requires running proxy) | -| `meridian profile login ` | Re-authenticate an expired profile (browser-login profiles only) | +| `meridian profile login [--headless]` | Re-authenticate an expired profile (browser-login profiles only); `--headless` uses the URL/code flow | | `meridian profile remove ` | Remove a profile and its credentials | | `meridian refresh-token` | Manually refresh the Claude OAuth token (exits 0/1) | diff --git a/bin/cli.ts b/bin/cli.ts index ccd97034..aea2a7cb 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -47,20 +47,21 @@ if (args[0] === "profile") { const { profileAdd, profileAddOauthToken, profileList, profileRemove, profileSwitch, profileLogin, profileHelp } = await import("../src/proxy/profileCli") const subcommand = args[1] const profileId = args[2] + const headless = args.includes("--headless") if (subcommand === "add" && profileId) { const oauthFlagIdx = args.indexOf("--oauth-token", 3) if (oauthFlagIdx >= 0) { const tokenArg = args[oauthFlagIdx + 1] - await profileAddOauthToken(profileId, tokenArg) + await profileAddOauthToken(profileId, tokenArg?.startsWith("--") ? undefined : tokenArg) } else { - profileAdd(profileId) + await profileAdd(profileId, { headless }) } } else if (subcommand === "list" || subcommand === "ls") profileList() else if (subcommand === "remove" && profileId) profileRemove(profileId) else if (subcommand === "switch" && profileId) await profileSwitch(profileId) - else if (subcommand === "login" && profileId) profileLogin(profileId) + else if (subcommand === "login" && profileId) await profileLogin(profileId, { headless }) else profileHelp() process.exit(0) } diff --git a/src/__tests__/profiles-unit.test.ts b/src/__tests__/profiles-unit.test.ts index 0f4bfe0c..e74c81fd 100644 --- a/src/__tests__/profiles-unit.test.ts +++ b/src/__tests__/profiles-unit.test.ts @@ -332,3 +332,37 @@ describe("dirsToRemoveOnProfileRemove", () => { expect(dirs).toEqual([]) }) }) + +describe("profile auth login env", () => { + test("sets CLAUDE_CONFIG_DIR without forcing browser behavior by default", async () => { + const { buildAuthLoginEnv } = await import("../proxy/profileCli") + const env = buildAuthLoginEnv("/tmp/profile", {}, { PATH: "/usr/bin", BROWSER: "open" }) + + expect(env.CLAUDE_CONFIG_DIR).toBe("/tmp/profile") + expect(env.BROWSER).toBe("open") + }) + + test("headless OAuth login builds a manual PKCE authorization URL", async () => { + const { createManualOAuthSession } = await import("../proxy/profileCli") + const session = createManualOAuthSession() + const url = new URL(session.authorizeUrl) + + expect(url.origin).toBe("https://claude.com") + expect(url.pathname).toBe("/cai/oauth/authorize") + expect(url.searchParams.get("code")).toBe("true") + expect(url.searchParams.get("client_id")).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e") + expect(url.searchParams.get("redirect_uri")).toBe("https://platform.claude.com/oauth/code/callback") + expect(url.searchParams.get("code_challenge_method")).toBe("S256") + expect(url.searchParams.get("code_challenge")).toBeTruthy() + expect(session.codeVerifier).toBeTruthy() + expect(session.state).toBeTruthy() + }) + + test("parses pasted authorization code values", async () => { + const { parseAuthorizationCodeInput } = await import("../proxy/profileCli") + + expect(parseAuthorizationCodeInput("abc123")).toEqual({ code: "abc123" }) + expect(parseAuthorizationCodeInput("abc123#state456")).toEqual({ code: "abc123", state: "state456" }) + expect(parseAuthorizationCodeInput("https://platform.claude.com/oauth/code/callback?code=abc123&state=state456")).toEqual({ code: "abc123", state: "state456" }) + }) +}) diff --git a/src/proxy/profileCli.ts b/src/proxy/profileCli.ts index 4292b107..dc83f02a 100644 --- a/src/proxy/profileCli.ts +++ b/src/proxy/profileCli.ts @@ -9,16 +9,30 @@ * This is a leaf module — no imports from server.ts or session/. */ -import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from "node:fs" -import { join } from "node:path" import { execFileSync, spawnSync } from "node:child_process" +import { createHash, randomBytes } from "node:crypto" +import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs" import { homedir } from "node:os" -import type { ProfileConfig } from "./profiles" +import { join } from "node:path" import { resolveClaudeExecutableSync } from "./models" +import type { ProfileConfig } from "./profiles" import { setSetting } from "./settings" +import { createPlatformCredentialStore } from "./tokenRefresh" const PROFILES_DIR = join(homedir(), ".config", "meridian", "profiles") const CONFIG_FILE = join(homedir(), ".config", "meridian", "profiles.json") +const OAUTH_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize" +const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token" +const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +const OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback" +const OAUTH_SCOPES = [ + "org:create_api_key", + "user:profile", + "user:inference", + "user:sessions:claude_code", + "user:mcp_servers", + "user:file_upload", +] function ensureProfilesDir(): void { mkdirSync(PROFILES_DIR, { recursive: true }) @@ -28,6 +42,78 @@ function getProfileDir(id: string): string { return join(PROFILES_DIR, id) } +interface AuthLoginOptions { + headless?: boolean +} + +interface ManualOAuthSession { + authorizeUrl: string + codeVerifier: string + state: string +} + +interface ParsedAuthorizationCode { + code: string + state?: string +} + +interface OAuthTokenResponse { + access_token: string + refresh_token?: string + expires_in?: number + expires_at?: number + scope?: string +} + +export function buildAuthLoginEnv( + configDir: string | undefined, + _options: AuthLoginOptions = {}, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv } + if (configDir) env.CLAUDE_CONFIG_DIR = configDir + return env +} + +function base64Url(bytes: Buffer): string { + return bytes.toString("base64url") +} + +export function createManualOAuthSession(): ManualOAuthSession { + const codeVerifier = base64Url(randomBytes(32)) + const state = base64Url(randomBytes(32)) + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url") + const url = new URL(OAUTH_AUTHORIZE_URL) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", OAUTH_CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI) + url.searchParams.set("scope", OAUTH_SCOPES.join(" ")) + url.searchParams.set("code_challenge", codeChallenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", state) + return { authorizeUrl: url.toString(), codeVerifier, state } +} + +export function parseAuthorizationCodeInput(input: string): ParsedAuthorizationCode | null { + const trimmed = input.trim() + if (!trimmed) return null + + try { + const url = new URL(trimmed) + const code = url.searchParams.get("code") ?? new URLSearchParams(url.hash.replace(/^#/, "")).get("code") + const state = url.searchParams.get("state") ?? new URLSearchParams(url.hash.replace(/^#/, "")).get("state") ?? undefined + return code ? { code, state } : null + } catch {} + + const [codePart, hashState] = trimmed.split("#", 2) + if (!codePart) return null + const ampersandParams = codePart.includes("&") ? new URLSearchParams(codePart.slice(codePart.indexOf("&") + 1)) : null + const code = codePart.split("&", 1)[0]?.trim() + const state = ampersandParams?.get("state") ?? hashState?.trim() ?? undefined + return code ? { code, state } : null +} + function loadProfileConfig(): ProfileConfig[] { if (!existsSync(CONFIG_FILE)) return [] try { @@ -40,7 +126,7 @@ function loadProfileConfig(): ProfileConfig[] { function saveProfileConfig(profiles: ProfileConfig[]): void { ensureProfilesDir() - writeFileSync(CONFIG_FILE, JSON.stringify(profiles, null, 2) + "\n", { mode: 0o600 }) + writeFileSync(CONFIG_FILE, `${JSON.stringify(profiles, null, 2)}\n`, { mode: 0o600 }) } function getAuthStatus(configDir: string): { loggedIn: boolean; email?: string; subscriptionType?: string } { @@ -69,7 +155,77 @@ function getAuthStatus(configDir: string): { loggedIn: boolean; email?: string; } } -export function profileAdd(id: string): void { +async function completeManualOAuthLogin(configDir: string): Promise { + const session = createManualOAuthSession() + console.log("\x1b[33m⚠ Headless OAuth login: open this URL in a browser:\x1b[0m") + console.log() + console.log(session.authorizeUrl) + console.log() + console.log("After sign-in, paste the code shown by Claude below.") + const input = promptLine("Paste code:") + const parsed = parseAuthorizationCodeInput(input) + if (!parsed) { + console.error("\x1b[31m✗ No authorization code received.\x1b[0m") + return false + } + if (parsed.state && parsed.state !== session.state) { + console.error("\x1b[31m✗ OAuth state mismatch. Please retry the login.\x1b[0m") + return false + } + + let response: Response + try { + response = await fetch(OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: OAUTH_CLIENT_ID, + code: parsed.code, + redirect_uri: OAUTH_REDIRECT_URI, + code_verifier: session.codeVerifier, + state: parsed.state ?? session.state, + }), + signal: AbortSignal.timeout(30_000), + }) + } catch (err) { + console.error(`\x1b[31m✗ OAuth token exchange failed: ${err instanceof Error ? err.message : err}\x1b[0m`) + return false + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + console.error(`\x1b[31m✗ OAuth token exchange failed (${response.status}).\x1b[0m`) + if (body) console.error(` ${body.slice(0, 300)}`) + return false + } + + let tokenData: OAuthTokenResponse + try { + tokenData = await response.json() as OAuthTokenResponse + } catch (err) { + console.error(`\x1b[31m✗ OAuth token response was invalid: ${err instanceof Error ? err.message : err}\x1b[0m`) + return false + } + + if (!tokenData.access_token || !tokenData.refresh_token) { + console.error("\x1b[31m✗ OAuth token response did not include the required tokens.\x1b[0m") + return false + } + + const expiresAt = tokenData.expires_at ?? Date.now() + (tokenData.expires_in ?? 8 * 60 * 60) * 1000 + const store = createPlatformCredentialStore({ claudeConfigDir: configDir }) + return store.write({ + claudeAiOauth: { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + scopes: tokenData.scope?.split(" ").filter(Boolean) ?? OAUTH_SCOPES, + }, + }) +} + +export async function profileAdd(id: string, options: AuthLoginOptions = {}): Promise { if (!id || /[^a-zA-Z0-9_-]/.test(id)) { console.error("\x1b[31m✗ Invalid profile ID.\x1b[0m Use only letters, numbers, hyphens, underscores.") process.exit(1) @@ -121,39 +277,49 @@ export function profileAdd(id: string): void { return } - console.log("\x1b[33m⚠ Important: Before logging in, make sure you're signed into the") - console.log(` correct Claude account in your browser (the one for "${id}").\x1b[0m`) - console.log() - console.log(" If you're currently signed into a different account:") - console.log(" 1. Go to https://claude.ai and sign out") - console.log(" 2. Sign in with the account you want for this profile") - console.log(" 3. Come back here — the login will open your browser") - console.log() - console.log(" Press Ctrl+C to cancel, or wait for the browser to open...") + if (!options.headless) { + console.log("\x1b[33m⚠ Important: Before logging in, make sure you're signed into the") + console.log(` correct Claude account in your browser (the one for "${id}").\x1b[0m`) + console.log() + console.log(" If you're currently signed into a different account:") + console.log(" 1. Go to https://claude.ai and sign out") + console.log(" 2. Sign in with the account you want for this profile") + console.log(" 3. Come back here — the login will open your browser") + console.log() + console.log(" Press Ctrl+C to cancel, or wait for the browser to open...") + } console.log() - // Run claude auth login with the profile's config dir. Route through - // the sync resolver so we don't depend on `claude` being on PATH (#478). - const resolvedAuth = resolveClaudeExecutableSync() - if (!resolvedAuth) { - console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m") - console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude") - process.exit(1) - } - const result = spawnSync(resolvedAuth.path, ["auth", "login"], { - env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, - stdio: "inherit", - }) + if (options.headless) { + const success = await completeManualOAuthLogin(configDir) + if (!success) { + console.error("\x1b[31m✗ Login failed.\x1b[0m") + process.exit(1) + } + } else { + // Run claude auth login with the profile's config dir. Route through + // the sync resolver so we don't depend on `claude` being on PATH (#478). + const resolvedAuth = resolveClaudeExecutableSync() + if (!resolvedAuth) { + console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m") + console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude") + process.exit(1) + } + const result = spawnSync(resolvedAuth.path, ["auth", "login"], { + env: buildAuthLoginEnv(configDir, options), + stdio: "inherit", + }) - if (result.status !== 0) { - console.error("\x1b[31m✗ Login failed.\x1b[0m") - process.exit(1) + if (result.status !== 0) { + console.error("\x1b[31m✗ Login failed.\x1b[0m") + process.exit(1) + } } // Verify auth succeeded const auth = getAuthStatus(configDir) if (!auth.loggedIn) { - console.error("\x1b[31m✗ Login did not complete. Try again: meridian profile add " + id + "\x1b[0m") + console.error(`\x1b[31m✗ Login did not complete. Try again: meridian profile add ${id}\x1b[0m`) process.exit(1) } @@ -232,7 +398,7 @@ export function profileList(): void { */ export function dirsToRemoveOnProfileRemove(profile: ProfileConfig, profilesDir: string): string[] { const dirs: string[] = [] - if (profile.claudeConfigDir && profile.claudeConfigDir.startsWith(profilesDir)) { + if (profile.claudeConfigDir?.startsWith(profilesDir)) { dirs.push(profile.claudeConfigDir) } if (profile.oauthToken || profile.type === "oauth-token") { @@ -250,7 +416,11 @@ export function profileRemove(id: string): void { process.exit(1) } - const removed = profiles[idx]! + const removed = profiles[idx] + if (!removed) { + console.error(`\x1b[31m✗ Profile "${id}" not found.\x1b[0m`) + process.exit(1) + } const dirsToRemove = dirsToRemoveOnProfileRemove(removed, PROFILES_DIR) profiles.splice(idx, 1) saveProfileConfig(profiles) @@ -291,7 +461,7 @@ export async function profileSwitch(id: string): Promise { } } -export function profileLogin(id: string): void { +export async function profileLogin(id: string, options: AuthLoginOptions = {}): Promise { const profiles = loadProfileConfig() const profile = profiles.find(p => p.id === id) if (!profile) { @@ -307,24 +477,34 @@ export function profileLogin(id: string): void { console.log(`\x1b[36mRe-authenticating profile: ${id}\x1b[0m`) console.log() - console.log("\x1b[33m⚠ Make sure you're signed into the correct Claude account in your browser.\x1b[0m") + if (!options.headless) { + console.log("\x1b[33m⚠ Make sure you're signed into the correct Claude account in your browser.\x1b[0m") + } console.log() - // Route through the sync resolver — see profileAdd above (#478). - const resolvedLogin = resolveClaudeExecutableSync() - if (!resolvedLogin) { - console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m") - console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude") - process.exit(1) - } - const result = spawnSync(resolvedLogin.path, ["auth", "login"], { - env: { ...process.env, CLAUDE_CONFIG_DIR: profile.claudeConfigDir }, - stdio: "inherit", - }) + if (options.headless) { + const success = await completeManualOAuthLogin(profile.claudeConfigDir ?? getProfileDir(id)) + if (!success) { + console.error("\x1b[31m✗ Login failed.\x1b[0m") + process.exit(1) + } + } else { + // Route through the sync resolver — see profileAdd above (#478). + const resolvedLogin = resolveClaudeExecutableSync() + if (!resolvedLogin) { + console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m") + console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude") + process.exit(1) + } + const result = spawnSync(resolvedLogin.path, ["auth", "login"], { + env: buildAuthLoginEnv(profile.claudeConfigDir, options), + stdio: "inherit", + }) - if (result.status !== 0) { - console.error("\x1b[31m✗ Login failed.\x1b[0m") - process.exit(1) + if (result.status !== 0) { + console.error("\x1b[31m✗ Login failed.\x1b[0m") + process.exit(1) + } } const auth = getAuthStatus(profile.claudeConfigDir ?? "") @@ -348,6 +528,16 @@ function promptYesNo(question: string): boolean { return answer !== "n" && answer !== "no" } +function promptLine(question: string): string { + process.stderr.write(`${question} `) + const result = spawnSync("node", ["-e", [ + `const rl = require("readline").createInterface({ input: process.stdin });`, + `rl.once("line", (a) => { process.stdout.write(a); rl.close(); });`, + `rl.once("close", () => process.exit(0));`, + ].join("\n")], { stdio: ["inherit", "pipe", "inherit"] }) + return result.stdout?.toString().trim() ?? "" +} + /** Synchronous secret prompt. Reads one line from stdin without echoing typed * characters (TTY). Falls back to a piped read when stdin is not a TTY so * `echo $TOKEN | meridian profile add ci --oauth-token` keeps working. */ @@ -393,20 +583,22 @@ export function profileHelp(): void { console.log(`meridian profile — manage Claude account profiles Commands: - meridian profile add Add a profile via browser login + meridian profile add [--headless] Add a profile via Claude OAuth login meridian profile add --oauth-token [TOKEN] Add a profile from a \`claude setup-token\` value (if TOKEN is omitted, you will be prompted; input is hidden) meridian profile list List profiles and auth status meridian profile remove Remove a profile meridian profile switch Switch the active profile (requires running proxy) - meridian profile login Re-authenticate an existing profile (claude-max only) + meridian profile login [--headless] Re-authenticate an existing profile (claude-max only) Examples: meridian profile add personal # Add personal account (browser login) meridian profile add work # Add work account + meridian profile add work --headless # Print OAuth URL, prompt for returned code, store credentials meridian profile add ci --oauth-token # Add headless CI profile (prompted, no echo) meridian profile add ci --oauth-token sk-ant-oat01-... # Add headless CI profile (token from CLI argument) + meridian profile login work --headless # Re-authenticate via OAuth URL/code prompt meridian profile switch work # Switch to work account meridian profile list # Show all profiles`) }