mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
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:
parent
913cf0dc5e
commit
e3e840e08c
7 changed files with 356 additions and 116 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue