From b5515dd2f7378713b755ab367ec986c79efc747c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 14:17:43 -0500 Subject: [PATCH] core: add device flow authentication commands Allow users to authenticate via browser-based OAuth device flow with opencode login command. Includes login, logout, switch account, and workspaces list commands for managing multiple accounts. --- packages/opencode/src/account/index.ts | 132 ++++++++++++++++++++++- packages/opencode/src/cli/cmd/account.ts | 89 +++++++++++++++ packages/opencode/src/index.ts | 5 + 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/account.ts diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index c59eef54fd..630f990724 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -3,8 +3,6 @@ import { Database } from "@/storage/db" import { AccountTable } from "./account.sql" import z from "zod" -export * from "./control.sql" - export namespace Account { export const Account = z.object({ email: z.string(), @@ -60,4 +58,134 @@ export namespace Account { return json.access_token } + + export type Login = { + code: string + user: string + url: string + server: string + expiry: number + interval: number + } + + export async function login(url?: string): Promise { + const server = url ?? "https://web-14275-d60e67f5-pyqs0590.onporter.run" + const res = await fetch(`${server}/auth/device/code`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: "opencode-cli" }), + }) + + if (!res.ok) throw new Error(`Failed to initiate device flow: ${await res.text()}`) + + const json = (await res.json()) as { + device_code: string + user_code: string + verification_uri_complete: string + expires_in: number + interval: number + } + + const full = `${server}${json.verification_uri_complete}` + + return { + code: json.device_code, + user: json.user_code, + url: full, + server, + expiry: json.expires_in, + interval: json.interval, + } + } + + export async function poll( + input: Login, + ): Promise< + | { type: "success"; email: string } + | { type: "pending" } + | { type: "slow" } + | { type: "expired" } + | { type: "denied" } + | { type: "error"; msg: string } + > { + const res = await fetch(`${input.server}/auth/device/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: "opencode-cli", + }), + }) + + const json = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + email?: string + error?: string + error_description?: string + } + + if (json.access_token) { + let email = json.email + if (!email) { + const me = await fetch(`${input.server}/api/user`, { + headers: { authorization: `Bearer ${json.access_token}` }, + }) + const user = (await me.json()) as { email?: string } + if (!user.email) { + return { type: "error", msg: "No email in response" } + } + email = user.email + } + + const access = json.access_token + const expiry = Date.now() + json.expires_in! * 1000 + const refresh = json.refresh_token ?? "" + + Database.use((db) => { + db.update(AccountTable).set({ active: false }).run() + db.insert(AccountTable) + .values({ + email, + url: input.url, + access_token: access, + refresh_token: refresh, + token_expiry: expiry, + active: true, + }) + .onConflictDoUpdate({ + target: [AccountTable.email, AccountTable.url], + set: { + access_token: access, + refresh_token: refresh, + token_expiry: expiry, + active: true, + }, + }) + .run() + }) + + return { type: "success", email } + } + + if (json.error === "authorization_pending") { + return { type: "pending" } + } + + if (json.error === "slow_down") { + return { type: "slow" } + } + + if (json.error === "expired_token") { + return { type: "expired" } + } + + if (json.error === "access_denied") { + return { type: "denied" } + } + + return { type: "error", msg: json.error || JSON.stringify(json) } + } } diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts new file mode 100644 index 0000000000..00fbcf2085 --- /dev/null +++ b/packages/opencode/src/cli/cmd/account.ts @@ -0,0 +1,89 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { Account } from "@/account" + +export const LoginCommand = cmd({ + command: "login [url]", + describe: "log in to an opencode account", + builder: (yargs) => + yargs.positional("url", { + describe: "server URL", + type: "string", + }), + async handler(args) { + UI.empty() + prompts.intro("Log in") + + const url = args.url as string | undefined + const login = await Account.login(url) + + prompts.log.info("Go to: " + login.url) + prompts.log.info("Enter code: " + login.user) + + try { + const open = + process.platform === "darwin" + ? ["open", login.url] + : process.platform === "win32" + ? ["cmd", "/c", "start", login.url] + : ["xdg-open", login.url] + Bun.spawn(open, { stdout: "ignore", stderr: "ignore" }) + } catch {} + + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + + let wait = login.interval * 1000 + while (true) { + await Bun.sleep(wait) + + const result = await Account.poll(login) + + if (result.type === "success") { + spinner.stop("Logged in as " + result.email) + prompts.outro("Done") + return + } + + if (result.type === "pending") continue + + if (result.type === "slow") { + wait += 5000 + continue + } + + if (result.type === "expired") { + spinner.stop("Device code expired", 1) + return + } + + if (result.type === "denied") { + spinner.stop("Authorization denied", 1) + return + } + + spinner.stop("Error: " + result.msg, 1) + return + } + }, +}) + +export const LogoutCommand = cmd({ + command: "logout", + describe: "log out from an account", + async handler() {}, +}) + +export const SwitchCommand = cmd({ + command: "switch", + describe: "switch active workspace", + async handler() {}, +}) + +export const WorkspacesCommand = cmd({ + command: "workspaces", + aliases: ["workspace"], + describe: "list all workspaces", + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 66f5e4c832..e4b85e3902 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,6 +3,7 @@ import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" +import { LoginCommand, LogoutCommand, SwitchCommand, WorkspacesCommand } from "./cli/cmd/account" import { ProvidersCommand } from "./cli/cmd/providers" import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" @@ -129,6 +130,10 @@ let cli = yargs(hideBin(process.argv)) .command(RunCommand) .command(GenerateCommand) .command(DebugCommand) + .command(LoginCommand) + .command(LogoutCommand) + .command(SwitchCommand) + .command(WorkspacesCommand) .command(ProvidersCommand) .command(AgentCommand) .command(UpgradeCommand)