import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" const When = Schema.Struct({ key: Schema.String, op: Schema.Literals(["eq", "neq"]), value: Schema.String, }) const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, placeholder: optionalOmitUndefined(Schema.String), when: optionalOmitUndefined(When), }) const SelectOption = Schema.Struct({ label: Schema.String, value: Schema.String, hint: optionalOmitUndefined(Schema.String), }) const SelectPrompt = Schema.Struct({ type: Schema.Literal("select"), key: Schema.String, message: Schema.String, options: Schema.Array(SelectOption), when: optionalOmitUndefined(When), }) const Prompt = Schema.Union([TextPrompt, SelectPrompt]) export class Method extends Schema.Class("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, prompts: optionalOmitUndefined(Schema.Array(Prompt)), }) {} export const Methods = Schema.Record(Schema.String, Schema.Array(Method)) export type Methods = typeof Methods.Type export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ url: Schema.String, method: Schema.Literals(["auto", "code"]), instructions: Schema.String, }) {} export const AuthorizeInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), }) export type AuthorizeInput = Schema.Schema.Type export const CallbackInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), }) export type CallbackInput = Schema.Schema.Type export class OauthMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthMissing", { providerID: ProviderID, }) {} export class OauthCodeMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthCodeMissing", { providerID: ProviderID, }) {} export class OauthCallbackFailed extends Schema.TaggedErrorClass()( "ProviderAuthOauthCallbackFailed", {}, ) {} export class ValidationFailed extends Schema.TaggedErrorClass()("ProviderAuthValidationFailed", { field: Schema.String, message: Schema.String, }) {} export type Error = Auth.AuthError | OauthMissing | OauthCodeMissing | OauthCallbackFailed | ValidationFailed type Hook = NonNullable export interface Interface { readonly methods: () => Effect.Effect readonly authorize: ( input: { providerID: ProviderID } & AuthorizeInput, ) => Effect.Effect readonly callback: (input: { providerID: ProviderID } & CallbackInput) => Effect.Effect } interface State { hooks: Record pending: Map } export class Service extends Context.Service()("@opencode/ProviderAuth") {} export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service const plugin = yield* Plugin.Service const state = yield* InstanceState.make( Effect.fn("ProviderAuth.state")(function* () { const plugins = yield* plugin.list() return { hooks: Record.fromEntries( Arr.filterMap(plugins, (x) => x.auth?.provider !== undefined ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) : Result.failVoid, ), ), pending: new Map(), } }), ) const decode = Schema.decodeUnknownSync(Methods) const methods = Effect.fn("ProviderAuth.methods")(function* () { const hooks = (yield* InstanceState.get(state)).hooks return decode( Record.map(hooks, (item) => item.methods.map((method) => ({ type: method.type, label: method.label, ...(method.prompts && { prompts: method.prompts.map((prompt) => { if (prompt.type === "select") { return { type: "select" as const, key: prompt.key, message: prompt.message, options: prompt.options, ...(prompt.when && { when: prompt.when }), } } return { type: "text" as const, key: prompt.key, message: prompt.message, ...(prompt.placeholder && { placeholder: prompt.placeholder }), ...(prompt.when && { when: prompt.when }), } }), }), })), ), ) }) const authorize = Effect.fn("ProviderAuth.authorize")(function* ( input: { providerID: ProviderID } & AuthorizeInput, ) { const { hooks, pending } = yield* InstanceState.get(state) const method = hooks[input.providerID].methods[input.method] if (method.type !== "oauth") return if (method.prompts && input.inputs) { for (const prompt of method.prompts) { if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { const error = prompt.validate(input.inputs[prompt.key]) if (error) return yield* new ValidationFailed({ field: prompt.key, message: error }) } } } const result = yield* Effect.promise(() => method.authorize(input.inputs)) pending.set(input.providerID, result) return { url: result.url, method: result.method, instructions: result.instructions, } }) const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID } & CallbackInput) { const pending = (yield* InstanceState.get(state)).pending const match = pending.get(input.providerID) if (!match) return yield* new OauthMissing({ providerID: input.providerID }) if (match.method === "code" && !input.code) { return yield* new OauthCodeMissing({ providerID: input.providerID }) } const result = yield* Effect.promise(() => match.method === "code" ? match.callback(input.code!) : match.callback(), ) if (!result || result.type !== "success") return yield* new OauthCallbackFailed({}) if ("key" in result) { yield* auth.set(input.providerID, { type: "api", key: result.key, ...(result.metadata ? { metadata: result.metadata } : {}), }) } if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extra } = result yield* auth.set(input.providerID, { type: "oauth", access, refresh, expires, ...extra, }) } }) return Service.of({ methods, authorize, callback }) }), ) export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), ) export * as ProviderAuth from "./auth"