diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 1433856..68d9d5c 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -195,6 +195,7 @@ export interface DeviceCodeResponse { device_code: string; user_code: string; verification_uri: string; + verification_uri_complete?: string; expires_in: number; interval: number; } @@ -228,6 +229,54 @@ export type DeviceCodePollResponse = | DeviceCodePollSlowDown | DeviceCodePollExpired; +export interface DeviceCodeFlowOptions { + clientId?: string; +} + +interface DeviceCodeFlowCallbacks { + onCode: (code: string, verificationUri: string) => void; + onWaiting: () => void; + onAuthorized: (creds: VanaCredentials) => void | Promise; + onExpired: () => void; + onError: (error: Error) => void; +} + +interface OidcDiscoveryDocument { + device_authorization_endpoint?: unknown; + token_endpoint?: unknown; +} + +interface OAuthDeviceEndpoints { + deviceAuthorizationEndpoint: string; + tokenEndpoint: string; +} + +interface OAuthTokenSuccess { + access_token: string; + address?: string; + expires_at?: string; + expires_in?: number; + id_token?: string; + personal_server_session_token?: string; + personal_server_url?: string; + ps_access_token?: string; +} + +interface OAuthTokenError { + error?: string; + error_description?: string; +} + +interface StartedDeviceCodeFlow { + deviceCode: DeviceCodeResponse; + kind: "legacy" | "oauth"; + poll: () => Promise; + verificationUri: string; +} + +const OAUTH_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const DEFAULT_OAUTH_SCOPE = "openid profile offline_access"; + function resolveCredentialExpiry(params: { expiresAt?: string; expiresIn?: number; @@ -250,6 +299,29 @@ function getAccountUrl(): string { ); } +export function resolveOAuthClientId(accountUrl = getAccountUrl()): string { + const configured = + process.env.VANA_OAUTH_CLIENT_ID ?? process.env.VANA_ACCOUNT_CLIENT_ID; + if (configured?.trim()) { + return configured.trim(); + } + + try { + const hostname = new URL(accountUrl).hostname; + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname.includes("account-dev") + ) { + return "vana-cli-dev"; + } + } catch { + // Fall through to the production client id. + } + + return "vana-cli"; +} + /** * Request a device code from the auth server. */ @@ -292,6 +364,212 @@ export async function pollDeviceCode( return (await response.json()) as DeviceCodePollResponse; } +async function discoverOAuthDeviceEndpoints( + accountUrl: string, +): Promise { + try { + const response = await fetch( + `${accountUrl}/.well-known/openid-configuration`, + { + method: "GET", + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }, + ); + if (!response.ok) { + return null; + } + const doc = (await response.json()) as OidcDiscoveryDocument; + if ( + typeof doc.device_authorization_endpoint !== "string" || + typeof doc.token_endpoint !== "string" + ) { + return null; + } + return { + deviceAuthorizationEndpoint: doc.device_authorization_endpoint, + tokenEndpoint: doc.token_endpoint, + }; + } catch { + return null; + } +} + +async function requestOAuthDeviceCode( + endpoint: string, + clientId: string, +): Promise { + const body = new URLSearchParams({ + client_id: clientId, + scope: process.env.VANA_OAUTH_SCOPE ?? DEFAULT_OAUTH_SCOPE, + }); + const response = await fetch(endpoint, { + method: "POST", + headers: { + accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + const detail = await readOAuthErrorDetail(response); + throw new Error( + `Failed to start OAuth device flow: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`, + ); + } + + return (await response.json()) as DeviceCodeResponse; +} + +async function pollOAuthDeviceCode(params: { + clientId: string; + deviceCode: string; + tokenEndpoint: string; +}): Promise { + const body = new URLSearchParams({ + client_id: params.clientId, + device_code: params.deviceCode, + grant_type: OAUTH_DEVICE_CODE_GRANT, + }); + const response = await fetch(params.tokenEndpoint, { + method: "POST", + headers: { + accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + signal: AbortSignal.timeout(10_000), + }); + const raw = (await response.json().catch(() => ({}))) as unknown; + const parsed = + raw && typeof raw === "object" + ? (raw as OAuthTokenSuccess | OAuthTokenError) + : {}; + + if (response.ok && "access_token" in parsed) { + return oauthTokenToAuthorized(parsed); + } + + const errorBody = parsed as OAuthTokenError; + const oauthError = + typeof errorBody.error === "string" ? errorBody.error : "unknown_error"; + if (oauthError === "authorization_pending") { + return { status: "pending" }; + } + if (oauthError === "slow_down") { + return { status: "slow_down" }; + } + if (oauthError === "expired_token") { + return { status: "expired" }; + } + + const description = + typeof errorBody.error_description === "string" + ? errorBody.error_description + : oauthError; + throw new Error(`OAuth device authorization failed: ${description}`); +} + +function oauthTokenToAuthorized( + token: OAuthTokenSuccess, +): DeviceCodePollAuthorized { + return { + status: "authorized", + address: resolveOAuthAccountAddress(token), + session_token: token.access_token, + expires_at: token.expires_at, + expires_in: token.expires_in, + personal_server_url: token.personal_server_url, + personal_server_session_token: token.personal_server_session_token, + ps_access_token: token.ps_access_token, + }; +} + +async function readOAuthErrorDetail(response: Response): Promise { + const text = await response.text().catch(() => ""); + if (!text) { + return ""; + } + try { + const parsed = JSON.parse(text) as OAuthTokenError; + return parsed.error_description ?? parsed.error ?? text.slice(0, 500); + } catch { + return text.slice(0, 500); + } +} + +function resolveOAuthAccountAddress(token: OAuthTokenSuccess): string { + if (token.address) { + return token.address; + } + const claims = decodeJwtClaims(token.id_token); + for (const key of ["wallet_address", "wallet", "eth_address", "address"]) { + const value = claims?.[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + const sub = claims?.sub; + if (typeof sub === "string" && isWalletAddress(sub)) { + return sub; + } + return "vana-account"; +} + +function decodeJwtClaims( + idToken: string | undefined, +): Record | null { + if (!idToken) { + return null; + } + const [, payload] = idToken.split("."); + if (!payload) { + return null; + } + try { + return JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as Record | null; + } catch { + return null; + } +} + +async function startDeviceCodeFlow( + options: DeviceCodeFlowOptions, +): Promise { + const accountUrl = getAccountUrl(); + const endpoints = await discoverOAuthDeviceEndpoints(accountUrl); + if (endpoints) { + const clientId = options.clientId ?? resolveOAuthClientId(accountUrl); + const deviceCode = await requestOAuthDeviceCode( + endpoints.deviceAuthorizationEndpoint, + clientId, + ); + return { + deviceCode, + kind: "oauth", + poll: () => + pollOAuthDeviceCode({ + clientId, + deviceCode: deviceCode.device_code, + tokenEndpoint: endpoints.tokenEndpoint, + }), + verificationUri: + deviceCode.verification_uri_complete ?? deviceCode.verification_uri, + }; + } + + const deviceCode = await requestDeviceCode(); + return { + deviceCode, + kind: "legacy", + poll: () => pollDeviceCode(deviceCode.device_code), + verificationUri: deviceCode.verification_uri, + }; +} + /** * Open a URL in the user's default browser. * Best-effort — failures are silently ignored. @@ -320,31 +598,29 @@ export function openBrowser(url: string): void { * Returns credentials on success, or null if the code expired. * Calls the provided callbacks for UI updates. */ -export async function runDeviceCodeFlow(callbacks: { - onCode: (code: string, verificationUri: string) => void; - onWaiting: () => void; - onAuthorized: (creds: VanaCredentials) => void | Promise; - onExpired: () => void; - onError: (error: Error) => void; -}): Promise { +export async function runDeviceCodeFlow( + callbacks: DeviceCodeFlowCallbacks, + options: DeviceCodeFlowOptions = {}, +): Promise { try { - const deviceCode = await requestDeviceCode(); + const flow = await startDeviceCodeFlow(options); + const { deviceCode } = flow; - callbacks.onCode(deviceCode.user_code, deviceCode.verification_uri); + callbacks.onCode(deviceCode.user_code, flow.verificationUri); // Try to open browser - openBrowser(deviceCode.verification_uri); + openBrowser(flow.verificationUri); callbacks.onWaiting(); - const interval = (deviceCode.interval ?? 5) * 1000; + let interval = (deviceCode.interval ?? 5) * 1000; const deadline = Date.now() + deviceCode.expires_in * 1000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, interval)); try { - const result = await pollDeviceCode(deviceCode.device_code); + const result = await flow.poll(); if (result.status === "authorized") { const expiresAt = resolveCredentialExpiry({ @@ -382,12 +658,17 @@ export async function runDeviceCodeFlow(callbacks: { } if (result.status === "slow_down") { + interval += 5_000; continue; } // status === "pending" — continue polling - } catch { - // Transient poll error — retry on next interval + } catch (error) { + if (flow.kind === "legacy") { + // Transient legacy poll error — retry on next interval + continue; + } + throw error; } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 439a301..ffc886b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -127,6 +127,11 @@ interface GlobalOptions { detach?: boolean; } +interface LoginCommandOptions { + clientId?: string; + server?: string; +} + interface SourceLabelMap { [source: string]: string; } @@ -615,10 +620,12 @@ Examples: .command("login") .description("Log in to your Vana account or a self-hosted Personal Server") .option("-s, --server ", "Self-hosted Personal Server URL") - .action(async (loginOptions: { server?: string }) => { + .option("--client-id ", "OAuth public client ID for Vana Account login") + .action(async (loginOptions: LoginCommandOptions) => { process.exitCode = await runCommandWithTelemetry( { ...telemetryBaseContext, command: "login" }, - async () => runLogin(parsedOptions, loginOptions.server), + async () => + runLogin(parsedOptions, loginOptions.server, loginOptions.clientId), ); }); @@ -6026,6 +6033,7 @@ async function runSkillShow( async function runLogin( options: GlobalOptions, serverUrl?: string, + clientId?: string, ): Promise { // Determine auth target: cloud (account.vana.org) or self-hosted (PS directly) const psUrl = serverUrl ?? resolvePersonalServerUrl() ?? null; @@ -6143,26 +6151,31 @@ async function runLogin( if (options.json) { // JSON mode: run flow and output result - const creds = await runDeviceCodeFlow({ - onCode: (code, uri) => { - process.stderr.write( - JSON.stringify({ event: "device_code", code, uri }) + "\n", - ); - }, - onWaiting: () => {}, - onAuthorized: () => {}, - onExpired: () => { - process.stdout.write( - JSON.stringify({ status: "expired", error: "Device code expired" }) + - "\n", - ); - }, - onError: (err) => { - process.stdout.write( - JSON.stringify({ status: "error", error: err.message }) + "\n", - ); + const creds = await runDeviceCodeFlow( + { + onCode: (code, uri) => { + process.stderr.write( + JSON.stringify({ event: "device_code", code, uri }) + "\n", + ); + }, + onWaiting: () => {}, + onAuthorized: () => {}, + onExpired: () => { + process.stdout.write( + JSON.stringify({ + status: "expired", + error: "Device code expired", + }) + "\n", + ); + }, + onError: (err) => { + process.stdout.write( + JSON.stringify({ status: "error", error: err.message }) + "\n", + ); + }, }, - }); + { clientId }, + ); if (creds) { await saveCredentials(creds); @@ -6195,42 +6208,47 @@ async function runLogin( }; renderer.title("Vana"); - const creds = await runDeviceCodeFlow({ - onCode: (code, uri) => { - writeStaticLoginNotes([ - "Open this URL in your browser:", - uri, - `Enter this code: ${code}`, - ]); - }, - onWaiting: () => { - renderer.scopeActive("Waiting for authorization"); - }, - onAuthorized: async (authedCreds) => { - await saveCredentials(authedCreds); - if (authedCreds.personal_server?.url) { - await updateCliConfig({ - personalServerUrl: authedCreds.personal_server.url, - }); - } - renderer.success( - `Logged in as ${formatAddress(authedCreds.account.address)}`, - ); - if (authedCreds.personal_server) { - renderer.detail(`Personal Server: ${authedCreds.personal_server.url}`); - } - renderer.detail("Credentials saved to ~/.vana/auth.json"); - }, - onExpired: () => { - renderer.fail("Authorization expired"); - renderer.next("vana login"); - }, - onError: (err) => { - renderer.fail("Login failed"); - renderer.detail(err.message); - renderer.next("vana login"); + const creds = await runDeviceCodeFlow( + { + onCode: (code, uri) => { + writeStaticLoginNotes([ + "Open this URL in your browser:", + uri, + `Enter this code: ${code}`, + ]); + }, + onWaiting: () => { + renderer.scopeActive("Waiting for authorization"); + }, + onAuthorized: async (authedCreds) => { + await saveCredentials(authedCreds); + if (authedCreds.personal_server?.url) { + await updateCliConfig({ + personalServerUrl: authedCreds.personal_server.url, + }); + } + renderer.success( + `Logged in as ${formatAddress(authedCreds.account.address)}`, + ); + if (authedCreds.personal_server) { + renderer.detail( + `Personal Server: ${authedCreds.personal_server.url}`, + ); + } + renderer.detail("Credentials saved to ~/.vana/auth.json"); + }, + onExpired: () => { + renderer.fail("Authorization expired"); + renderer.next("vana login"); + }, + onError: (err) => { + renderer.fail("Login failed"); + renderer.detail(err.message); + renderer.next("vana login"); + }, }, - }); + { clientId }, + ); renderer.cleanup(); diff --git a/test/cli/auth.test.ts b/test/cli/auth.test.ts index 9847ca8..b33f7f1 100644 --- a/test/cli/auth.test.ts +++ b/test/cli/auth.test.ts @@ -14,6 +14,7 @@ vi.mock("node:child_process", () => ({ import { getAuthTarget, loadCredentials, + resolveOAuthClientId, runDeviceCodeFlow, runSelfHostedLoginFlow, } from "../../src/cli/auth.js"; @@ -267,14 +268,32 @@ describe("runDeviceCodeFlow", () => { fetchMock.mockReset(); mocks.spawnSync.mockReset(); vi.stubGlobal("fetch", fetchMock); + delete process.env.VANA_ACCOUNT_URL; + delete process.env.VANA_ACCOUNT_CLIENT_ID; + delete process.env.VANA_OAUTH_CLIENT_ID; + delete process.env.VANA_OAUTH_SCOPE; }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); + delete process.env.VANA_ACCOUNT_URL; + delete process.env.VANA_ACCOUNT_CLIENT_ID; + delete process.env.VANA_OAUTH_CLIENT_ID; + delete process.env.VANA_OAUTH_SCOPE; }); + function mockDiscoveryUnavailable() { + fetchMock.mockResolvedValueOnce( + new Response("not found", { + status: 404, + headers: { "Content-Type": "text/plain" }, + }), + ); + } + it("prefers server-issued expires_at over locally invented expiry", async () => { + mockDiscoveryUnavailable(); fetchMock .mockResolvedValueOnce( new Response( @@ -342,6 +361,7 @@ describe("runDeviceCodeFlow", () => { }); it("accepts legacy ps_access_token responses from account login polling", async () => { + mockDiscoveryUnavailable(); fetchMock .mockResolvedValueOnce( new Response( @@ -388,6 +408,7 @@ describe("runDeviceCodeFlow", () => { }); it("continues polling when the account service asks the CLI to slow down", async () => { + mockDiscoveryUnavailable(); fetchMock .mockResolvedValueOnce( new Response( @@ -429,7 +450,7 @@ describe("runDeviceCodeFlow", () => { onError: vi.fn(), }); - await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(15_000); await expect(promise).resolves.toMatchObject({ account: { @@ -442,6 +463,126 @@ describe("runDeviceCodeFlow", () => { }, }); }); + + it("uses OAuth device flow when Account discovery advertises device endpoints", async () => { + process.env.VANA_ACCOUNT_URL = "https://account-dev.vana.org"; + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_authorization_endpoint: + "https://account-dev.vana.org/oauth/device/code", + token_endpoint: "https://account-dev.vana.org/oauth/token", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "oauth-device-123", + user_code: "WXYZ-1234", + verification_uri: "https://account-dev.vana.org/device", + verification_uri_complete: + "https://account-dev.vana.org/device?user_code=WXYZ-1234", + expires_in: 300, + interval: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "authorization_pending" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: "vana_account_oauth_token", + expires_at: "2026-04-22T00:00:00.000Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const onCode = vi.fn(); + const onAuthorized = vi.fn(); + const promise = runDeviceCodeFlow({ + onCode, + onWaiting: vi.fn(), + onAuthorized, + onExpired: vi.fn(), + onError: vi.fn(), + }); + + await vi.advanceTimersByTimeAsync(10_000); + + await expect(promise).resolves.toMatchObject({ + account: { + address: "vana-account", + session_token: "vana_account_oauth_token", + expires_at: "2026-04-22T00:00:00.000Z", + }, + personal_server: null, + }); + expect(onCode).toHaveBeenCalledWith( + "WXYZ-1234", + "https://account-dev.vana.org/device?user_code=WXYZ-1234", + ); + expect(mocks.spawnSync).toHaveBeenCalledWith( + expect.any(String), + ["https://account-dev.vana.org/device?user_code=WXYZ-1234"], + expect.any(Object), + ); + + const [, deviceInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(fetchMock.mock.calls[1][0]).toBe( + "https://account-dev.vana.org/oauth/device/code", + ); + expect((deviceInit.body as URLSearchParams).get("client_id")).toBe( + "vana-cli-dev", + ); + expect((deviceInit.body as URLSearchParams).get("scope")).toBe( + "openid profile offline_access", + ); + + const [, tokenInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(fetchMock.mock.calls[2][0]).toBe( + "https://account-dev.vana.org/oauth/token", + ); + expect((tokenInit.body as URLSearchParams).get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:device_code", + ); + expect((tokenInit.body as URLSearchParams).get("client_id")).toBe( + "vana-cli-dev", + ); + }); +}); + +describe("resolveOAuthClientId", () => { + afterEach(() => { + delete process.env.VANA_ACCOUNT_CLIENT_ID; + delete process.env.VANA_OAUTH_CLIENT_ID; + }); + + it("uses configured client IDs before URL-based defaults", () => { + process.env.VANA_OAUTH_CLIENT_ID = "custom-cli"; + expect(resolveOAuthClientId("https://account-dev.vana.org")).toBe( + "custom-cli", + ); + }); + + it("defaults dev Account URLs to the dev CLI client", () => { + expect(resolveOAuthClientId("https://account-dev.vana.org")).toBe( + "vana-cli-dev", + ); + }); + + it("defaults production Account URLs to the production CLI client", () => { + expect(resolveOAuthClientId("https://account.vana.org")).toBe("vana-cli"); + }); }); describe("loadCredentials", () => { diff --git a/test/cli/index.test.ts b/test/cli/index.test.ts index 61a5016..1d903c9 100644 --- a/test/cli/index.test.ts +++ b/test/cli/index.test.ts @@ -562,6 +562,35 @@ describe("runCli", () => { expect(stderr).not.toContain("Logging in to Vana..."); }); + it("passes an explicit OAuth client ID to cloud login", async () => { + mockRunDeviceCodeFlow.mockImplementation(async (callbacks) => { + const creds = { + account: { + address: "vana-account", + session_token: "vana_account_session", + expires_at: "2026-04-22T19:25:14.420Z", + }, + personal_server: null, + }; + await callbacks.onAuthorized(creds); + return creds; + }); + + const { runCli } = await import("../../src/cli/index.js"); + const exitCode = await runCli([ + "node", + "vana", + "login", + "--client-id", + "custom-cli", + ]); + + expect(exitCode).toBe(0); + expect(mockRunDeviceCodeFlow).toHaveBeenCalledWith(expect.any(Object), { + clientId: "custom-cli", + }); + }); + it("prints telemetry status in json mode", async () => { mockGetTelemetryStatus.mockResolvedValue({ enabled: false,