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.
This commit is contained in:
Dax Raad 2025-11-20 00:45:49 -05:00
parent 913cf0dc5e
commit e3e840e08c
7 changed files with 356 additions and 116 deletions

View file

@ -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<string, number> = {
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<number | null>((resolve) => {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
@ -51,8 +52,22 @@ export function createDialogProviderOptions() {
() => 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)
}
}
},
})),

View file

@ -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 (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.()
}}
width={dimensions().width}
@ -29,6 +33,7 @@ export function Dialog(
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box position="absolute">
<box
position="absolute"
onMouseUp={async () => {
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()
}
}}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}

View file

@ -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<string, AuthOuathResult> }
})
export const Method = z.object({
@ -21,7 +25,7 @@ export namespace ProviderAuth {
export type Method = z.infer<typeof Method>
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<typeof AuthorizeResult>
export const authorize = fn(
z.object({
providerID: z.string(),
method: z.number(),
}),
async (input): Promise<AuthorizeResult | undefined> => {
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(),
}),
)
}

View file

@ -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({

View file

@ -26,120 +26,122 @@ export type PluginInput = {
export type Plugin = (input: PluginInput) => Promise<Hooks>
export type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
}
| {
type: "api"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize?(inputs?: Record<string, string>): 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<void>
config?: (input: Config) => Promise<void>
tool?: {
[key: string]: ToolDefinition
}
auth?: {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize(inputs?: Record<string, string>): 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<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize?(inputs?: Record<string, string>): Promise<
| {
type: "success"
key: string
provider?: string
}
| {
type: "failed"
}
>
}
)[]
}
auth?: AuthHook
/**
* Called when a new message is received
*/

View file

@ -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<ThrowOnError extends boolean = false>(options: Options<ProviderOauthAuthorizeData, ThrowOnError>) {
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 {

View file

@ -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