From e3e840e08c9bfb652e010316d0d2fd8211443099 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 20 Nov 2025 00:45:49 -0500 Subject: [PATCH] tui: enable OAuth provider authentication with code and auto flows Users can now authenticate with providers using OAuth in addition to API keys. The TUI presents auth method options when connecting a provider, with support for both automatic and code-based OAuth flows that display authorization URLs with instructions. --- .../cli/cmd/tui/component/dialog-provider.tsx | 23 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 25 +- packages/opencode/src/provider/auth.ts | 102 +++++++- packages/opencode/src/server/server.ts | 39 ++++ packages/plugin/src/index.ts | 218 +++++++++--------- packages/sdk/js/src/gen/sdk.gen.ts | 24 ++ packages/sdk/js/src/gen/types.gen.ts | 41 ++++ 7 files changed, 356 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index a36219e78..cf1d0631f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -4,6 +4,7 @@ import { map, pipe, sortBy } from "remeda" import { DialogSelect } from "@tui/ui/dialog-select" import { Dialog, useDialog } from "@tui/ui/dialog" import { useSDK } from "../context/sdk" +import { DialogPrompt } from "../ui/dialog-prompt" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -33,9 +34,9 @@ export function createDialogProviderOptions() { }[provider.id], async onSelect() { const methods = sync.data.provider_auth[provider.id] - let method = methods[0]?.type ?? "api" + let index: number | null = 0 if (methods.length > 1) { - const index = await new Promise((resolve) => { + index = await new Promise((resolve) => { dialog.replace( () => ( resolve(null), ) }) - if (!index) return - method = methods[index].type + } + if (index == null) return + const method = methods[index] + + if (method.type === "oauth") { + const result = await sdk.client.provider.oauth.authorize({ + path: { + id: provider.id, + }, + body: { + method: index, + }, + }) + if (result.data?.method === "code") { + await DialogPrompt.show(dialog, result.data.url + " " + result.data.instructions) + } } }, })), diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 14a9f7152..9b773111c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -3,6 +3,8 @@ import { batch, createContext, Show, useContext, type JSX, type ParentProps } fr import { useTheme } from "@tui/context/theme" import { Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" +import { Clipboard } from "@tui/util/clipboard" +import { useToast } from "./toast" export function Dialog( props: ParentProps<{ @@ -12,10 +14,12 @@ export function Dialog( ) { const dimensions = useTerminalDimensions() const { theme } = useTheme() + const renderer = useRenderer() return ( { + if (renderer.getSelection()) return props.onClose?.() }} width={dimensions().width} @@ -29,6 +33,7 @@ export function Dialog( > { + if (renderer.getSelection()) return e.stopPropagation() }} width={props.size === "large" ? 80 : 60} @@ -124,10 +129,28 @@ const ctx = createContext() export function DialogProvider(props: ParentProps) { const value = init() + const renderer = useRenderer() + const toast = useToast() return ( {props.children} - + { + const text = renderer.getSelection()?.getSelectedText() + if (text && text.length > 0) { + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + /* @ts-expect-error */ + renderer.writeOut(finalOsc52) + await Clipboard.copy(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + renderer.clearSelection() + } + }} + > value.clear()} size={value.size}> {value.stack.at(-1)!.element} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index b07eed2c5..af18ef600 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -2,16 +2,20 @@ import { Instance } from "@/project/instance" import { Plugin } from "../plugin" import { map, filter, pipe, fromEntries, mapValues } from "remeda" import z from "zod" +import { fn } from "@/util/fn" +import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" +import { NamedError } from "@/util/error" +import { Auth } from "@/auth" export namespace ProviderAuth { const state = Instance.state(async () => { - const result = pipe( + const methods = pipe( await Plugin.list(), filter((x) => x.auth?.provider !== undefined), map((x) => [x.auth!.provider, x.auth!] as const), fromEntries(), ) - return result + return { methods, pending: {} as Record } }) export const Method = z.object({ @@ -21,7 +25,7 @@ export namespace ProviderAuth { export type Method = z.infer export async function methods() { - const s = await state() + const s = await state().then((x) => x.methods) return mapValues(s, (x) => x.methods.map( (y): Method => ({ @@ -31,4 +35,96 @@ export namespace ProviderAuth { ), ) } + + export const AuthorizeResult = z.object({ + url: z.string(), + method: z.union([z.literal("auto"), z.literal("code")]), + instructions: z.string(), + }) + export type AuthorizeResult = z.infer + + export const authorize = fn( + z.object({ + providerID: z.string(), + method: z.number(), + }), + async (input): Promise => { + const auth = await state().then((s) => s.methods[input.providerID]) + const method = auth.methods[input.method] + if (method.type === "oauth") { + const result = await method.authorize() + await state().then((s) => (s.pending[input.providerID] = result)) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + } + }, + ) + + export const callback = fn( + z.object({ + providerID: z.string(), + method: z.number(), + code: z.string().optional(), + }), + async (input) => { + const match = await state().then((s) => s.pending[input.providerID]) + if (!match) throw new OauthMissing({ providerID: input.providerID }) + let result + if (match.method === "code") { + if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID }) + result = await match.callback(input.code) + } + if (match.method === "auto") { + result = await match.callback() + } + if (!result) return + if (result.type === "success") { + if ("key" in result) { + await Auth.set(input.providerID, { + type: "api", + key: result.key, + }) + return + } + if ("refresh" in result) { + await Auth.set(input.providerID, { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + }) + return + } + } + }, + ) + + export const api = fn( + z.object({ + providerID: z.string(), + key: z.string(), + }), + async (input) => { + await Auth.set(input.providerID, { + type: "api", + key: input.key, + }) + }, + ) + + export const OauthMissing = NamedError.create( + "ProviderAuthOauthMissing", + z.object({ + providerID: z.string(), + }), + ) + export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ + providerID: z.string(), + }), + ) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8c0cfbf46..5b6104ce0 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1237,6 +1237,45 @@ export namespace Server { return c.json(await ProviderAuth.methods()) }, ) + .post( + "/provider/:id/oauth/authorize", + describeRoute({ + description: "Authorize a provider using OAuth", + operationId: "provider.oauth.authorize", + responses: { + 200: { + description: "Authorization URL and method", + content: { + "application/json": { + schema: resolver(ProviderAuth.AuthorizeResult.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: z.string().meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + }), + ), + async (c) => { + const id = c.req.valid("param").id + const { method } = c.req.valid("json") + const result = await ProviderAuth.authorize({ + providerID: id, + method, + }) + return c.json(result) + }, + ) .get( "/find", describeRoute({ diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 0601e5877..ab7aac251 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -26,120 +26,122 @@ export type PluginInput = { export type Plugin = (input: PluginInput) => Promise +export type AuthHook = { + provider: string + loader?: (auth: () => Promise, provider: Provider) => Promise> + methods: ( + | { + type: "oauth" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + validate?: (value: string) => string | undefined + condition?: (inputs: Record) => boolean + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + condition?: (inputs: Record) => boolean + } + > + authorize(inputs?: Record): Promise + } + | { + type: "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + validate?: (value: string) => string | undefined + condition?: (inputs: Record) => boolean + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + condition?: (inputs: Record) => boolean + } + > + authorize?(inputs?: Record): Promise< + | { + type: "success" + key: string + provider?: string + } + | { + type: "failed" + } + > + } + )[] +} + +export type AuthOuathResult = { url: string; instructions: string } & ( + | { + method: "auto" + callback(): Promise< + | ({ + type: "success" + provider?: string + } & ( + | { + refresh: string + access: string + expires: number + } + | { key: string } + )) + | { + type: "failed" + } + > + } + | { + method: "code" + callback(code: string): Promise< + | ({ + type: "success" + provider?: string + } & ( + | { + refresh: string + access: string + expires: number + } + | { key: string } + )) + | { + type: "failed" + } + > + } +) + export interface Hooks { event?: (input: { event: Event }) => Promise config?: (input: Config) => Promise tool?: { [key: string]: ToolDefinition } - auth?: { - provider: string - loader?: (auth: () => Promise, provider: Provider) => Promise> - methods: ( - | { - type: "oauth" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - validate?: (value: string) => string | undefined - condition?: (inputs: Record) => boolean - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - condition?: (inputs: Record) => boolean - } - > - authorize(inputs?: Record): Promise< - { url: string; instructions: string } & ( - | { - method: "auto" - callback(): Promise< - | ({ - type: "success" - provider?: string - } & ( - | { - refresh: string - access: string - expires: number - } - | { key: string } - )) - | { - type: "failed" - } - > - } - | { - method: "code" - callback(code: string): Promise< - | ({ - type: "success" - provider?: string - } & ( - | { - refresh: string - access: string - expires: number - } - | { key: string } - )) - | { - type: "failed" - } - > - } - ) - > - } - | { - type: "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - validate?: (value: string) => string | undefined - condition?: (inputs: Record) => boolean - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - condition?: (inputs: Record) => boolean - } - > - authorize?(inputs?: Record): Promise< - | { - type: "success" - key: string - provider?: string - } - | { - type: "failed" - } - > - } - )[] - } + auth?: AuthHook /** * Called when a new message is received */ diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index d4d099073..2d99e85d6 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -98,6 +98,9 @@ import type { ProviderListResponses, ProviderAuthData, ProviderAuthResponses, + ProviderOauthAuthorizeData, + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, FindTextData, FindTextResponses, FindFilesData, @@ -572,6 +575,26 @@ class Command extends _HeyApiClient { } } +class Oauth extends _HeyApiClient { + /** + * Authorize a provider using OAuth + */ + public authorize(options: Options) { + return (options.client ?? this._client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{id}/oauth/authorize", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } +} + class Provider extends _HeyApiClient { /** * List all providers @@ -592,6 +615,7 @@ class Provider extends _HeyApiClient { ...options, }) } + oauth = new Oauth({ client: this._client }) } class Find extends _HeyApiClient { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index b158da9f9..08e139340 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -2547,6 +2547,47 @@ export type ProviderAuthResponses = { export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type ProviderOauthAuthorizeData = { + body?: { + /** + * Auth method index + */ + method: number + } + path: { + /** + * Provider ID + */ + id: string + } + query?: { + directory?: string + } + url: "/provider/{id}/oauth/authorize" +} + +export type ProviderOauthAuthorizeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: { + url: string + method: "auto" | "code" + instructions: string + } +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + export type FindTextData = { body?: never path?: never