auth.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
  2. import { NamedError } from "@opencode-ai/util/error"
  3. import { Auth } from "@/auth"
  4. import { InstanceState } from "@/effect/instance-state"
  5. import { makeRuntime } from "@/effect/run-service"
  6. import { Plugin } from "../plugin"
  7. import { ProviderID } from "./schema"
  8. import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect"
  9. import z from "zod"
  10. export namespace ProviderAuth {
  11. export const Method = z
  12. .object({
  13. type: z.union([z.literal("oauth"), z.literal("api")]),
  14. label: z.string(),
  15. prompts: z
  16. .array(
  17. z.union([
  18. z.object({
  19. type: z.literal("text"),
  20. key: z.string(),
  21. message: z.string(),
  22. placeholder: z.string().optional(),
  23. when: z
  24. .object({
  25. key: z.string(),
  26. op: z.union([z.literal("eq"), z.literal("neq")]),
  27. value: z.string(),
  28. })
  29. .optional(),
  30. }),
  31. z.object({
  32. type: z.literal("select"),
  33. key: z.string(),
  34. message: z.string(),
  35. options: z.array(
  36. z.object({
  37. label: z.string(),
  38. value: z.string(),
  39. hint: z.string().optional(),
  40. }),
  41. ),
  42. when: z
  43. .object({
  44. key: z.string(),
  45. op: z.union([z.literal("eq"), z.literal("neq")]),
  46. value: z.string(),
  47. })
  48. .optional(),
  49. }),
  50. ]),
  51. )
  52. .optional(),
  53. })
  54. .meta({
  55. ref: "ProviderAuthMethod",
  56. })
  57. export type Method = z.infer<typeof Method>
  58. export const Authorization = z
  59. .object({
  60. url: z.string(),
  61. method: z.union([z.literal("auto"), z.literal("code")]),
  62. instructions: z.string(),
  63. })
  64. .meta({
  65. ref: "ProviderAuthAuthorization",
  66. })
  67. export type Authorization = z.infer<typeof Authorization>
  68. export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
  69. export const OauthCodeMissing = NamedError.create(
  70. "ProviderAuthOauthCodeMissing",
  71. z.object({ providerID: ProviderID.zod }),
  72. )
  73. export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
  74. export const ValidationFailed = NamedError.create(
  75. "ProviderAuthValidationFailed",
  76. z.object({
  77. field: z.string(),
  78. message: z.string(),
  79. }),
  80. )
  81. export type Error =
  82. | Auth.AuthError
  83. | InstanceType<typeof OauthMissing>
  84. | InstanceType<typeof OauthCodeMissing>
  85. | InstanceType<typeof OauthCallbackFailed>
  86. | InstanceType<typeof ValidationFailed>
  87. type Hook = NonNullable<Hooks["auth"]>
  88. export interface Interface {
  89. readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
  90. readonly authorize: (input: {
  91. providerID: ProviderID
  92. method: number
  93. inputs?: Record<string, string>
  94. }) => Effect.Effect<Authorization | undefined, Error>
  95. readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
  96. }
  97. interface State {
  98. hooks: Record<ProviderID, Hook>
  99. pending: Map<ProviderID, AuthOAuthResult>
  100. }
  101. export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {}
  102. export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
  103. Service,
  104. Effect.gen(function* () {
  105. const auth = yield* Auth.Service
  106. const plugin = yield* Plugin.Service
  107. const state = yield* InstanceState.make<State>(
  108. Effect.fn("ProviderAuth.state")(function* () {
  109. const plugins = yield* plugin.list()
  110. return {
  111. hooks: Record.fromEntries(
  112. Arr.filterMap(plugins, (x) =>
  113. x.auth?.provider !== undefined
  114. ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
  115. : Result.failVoid,
  116. ),
  117. ),
  118. pending: new Map<ProviderID, AuthOAuthResult>(),
  119. }
  120. }),
  121. )
  122. const methods = Effect.fn("ProviderAuth.methods")(function* () {
  123. const hooks = (yield* InstanceState.get(state)).hooks
  124. return Record.map(hooks, (item) =>
  125. item.methods.map(
  126. (method): Method => ({
  127. type: method.type,
  128. label: method.label,
  129. prompts: method.prompts?.map((prompt) => {
  130. if (prompt.type === "select") {
  131. return {
  132. type: "select" as const,
  133. key: prompt.key,
  134. message: prompt.message,
  135. options: prompt.options,
  136. when: prompt.when,
  137. }
  138. }
  139. return {
  140. type: "text" as const,
  141. key: prompt.key,
  142. message: prompt.message,
  143. placeholder: prompt.placeholder,
  144. when: prompt.when,
  145. }
  146. }),
  147. }),
  148. ),
  149. )
  150. })
  151. const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
  152. providerID: ProviderID
  153. method: number
  154. inputs?: Record<string, string>
  155. }) {
  156. const { hooks, pending } = yield* InstanceState.get(state)
  157. const method = hooks[input.providerID].methods[input.method]
  158. if (method.type !== "oauth") return
  159. if (method.prompts && input.inputs) {
  160. for (const prompt of method.prompts) {
  161. if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
  162. const error = prompt.validate(input.inputs[prompt.key])
  163. if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
  164. }
  165. }
  166. }
  167. const result = yield* Effect.promise(() => method.authorize(input.inputs))
  168. pending.set(input.providerID, result)
  169. return {
  170. url: result.url,
  171. method: result.method,
  172. instructions: result.instructions,
  173. }
  174. })
  175. const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
  176. providerID: ProviderID
  177. method: number
  178. code?: string
  179. }) {
  180. const pending = (yield* InstanceState.get(state)).pending
  181. const match = pending.get(input.providerID)
  182. if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
  183. if (match.method === "code" && !input.code) {
  184. return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
  185. }
  186. const result = yield* Effect.promise(() =>
  187. match.method === "code" ? match.callback(input.code!) : match.callback(),
  188. )
  189. if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
  190. if ("key" in result) {
  191. yield* auth.set(input.providerID, {
  192. type: "api",
  193. key: result.key,
  194. })
  195. }
  196. if ("refresh" in result) {
  197. const { type: _, provider: __, refresh, access, expires, ...extra } = result
  198. yield* auth.set(input.providerID, {
  199. type: "oauth",
  200. access,
  201. refresh,
  202. expires,
  203. ...extra,
  204. })
  205. }
  206. })
  207. return Service.of({ methods, authorize, callback })
  208. }),
  209. )
  210. export const defaultLayer = Layer.suspend(() =>
  211. layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
  212. )
  213. const { runPromise } = makeRuntime(Service, defaultLayer)
  214. export async function methods() {
  215. return runPromise((svc) => svc.methods())
  216. }
  217. export async function authorize(input: {
  218. providerID: ProviderID
  219. method: number
  220. inputs?: Record<string, string>
  221. }): Promise<Authorization | undefined> {
  222. return runPromise((svc) => svc.authorize(input))
  223. }
  224. export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
  225. return runPromise((svc) => svc.callback(input))
  226. }
  227. }