auth.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  2. import type { AuthOAuthResult, Hooks } from "@kirincode-ai/plugin"
  3. import { serviceUse } from "@kirincode-ai/core/effect/service-use"
  4. import { Auth } from "@/auth"
  5. import { InstanceState } from "@/effect/instance-state"
  6. import { optional } from "@kirincode-ai/core/schema"
  7. import { Plugin } from "../plugin"
  8. import { ProviderV2 } from "@kirincode-ai/core/provider"
  9. import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
  10. const When = Schema.Struct({
  11. key: Schema.String,
  12. op: Schema.Literals(["eq", "neq"]),
  13. value: Schema.String,
  14. })
  15. const TextPrompt = Schema.Struct({
  16. type: Schema.Literal("text"),
  17. key: Schema.String,
  18. message: Schema.String,
  19. placeholder: optional(Schema.String),
  20. when: optional(When),
  21. })
  22. const SelectOption = Schema.Struct({
  23. label: Schema.String,
  24. value: Schema.String,
  25. hint: optional(Schema.String),
  26. })
  27. const SelectPrompt = Schema.Struct({
  28. type: Schema.Literal("select"),
  29. key: Schema.String,
  30. message: Schema.String,
  31. options: Schema.Array(SelectOption),
  32. when: optional(When),
  33. })
  34. const Prompt = Schema.Union([TextPrompt, SelectPrompt])
  35. export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
  36. type: Schema.Literals(["oauth", "api"]),
  37. label: Schema.String,
  38. prompts: optional(Schema.Array(Prompt)),
  39. }) {}
  40. export const Methods = Schema.Record(Schema.String, Schema.Array(Method))
  41. export type Methods = typeof Methods.Type
  42. export class Authorization extends Schema.Class<Authorization>("ProviderAuthAuthorization")({
  43. url: Schema.String,
  44. method: Schema.Literals(["auto", "code"]),
  45. instructions: Schema.String,
  46. }) {}
  47. export const AuthorizeInput = Schema.Struct({
  48. method: Schema.Finite.annotate({ description: "Auth method index" }),
  49. inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }),
  50. })
  51. export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
  52. export const CallbackInput = Schema.Struct({
  53. method: Schema.Finite.annotate({ description: "Auth method index" }),
  54. code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }),
  55. })
  56. export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
  57. export class OauthMissing extends Schema.TaggedErrorClass<OauthMissing>()("ProviderAuthOauthMissing", {
  58. providerID: ProviderV2.ID,
  59. }) {}
  60. export class OauthCodeMissing extends Schema.TaggedErrorClass<OauthCodeMissing>()("ProviderAuthOauthCodeMissing", {
  61. providerID: ProviderV2.ID,
  62. }) {}
  63. export class OauthCallbackFailed extends Schema.TaggedErrorClass<OauthCallbackFailed>()(
  64. "ProviderAuthOauthCallbackFailed",
  65. {},
  66. ) {}
  67. export class ValidationFailed extends Schema.TaggedErrorClass<ValidationFailed>()("ProviderAuthValidationFailed", {
  68. field: Schema.String,
  69. message: Schema.String,
  70. }) {}
  71. export type Error = Auth.AuthError | OauthMissing | OauthCodeMissing | OauthCallbackFailed | ValidationFailed
  72. type Hook = NonNullable<Hooks["auth"]>
  73. export interface Interface {
  74. readonly methods: () => Effect.Effect<Methods>
  75. readonly authorize: (
  76. input: {
  77. providerID: ProviderV2.ID
  78. } & AuthorizeInput,
  79. ) => Effect.Effect<Authorization | undefined, Error>
  80. readonly callback: (input: { providerID: ProviderV2.ID } & CallbackInput) => Effect.Effect<void, Error>
  81. }
  82. interface State {
  83. hooks: Record<ProviderV2.ID, Hook>
  84. pending: Map<ProviderV2.ID, AuthOAuthResult>
  85. }
  86. export class Service extends Context.Service<Service, Interface>()("@kirincode/ProviderAuth") {}
  87. export const use = serviceUse(Service)
  88. const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
  89. Service,
  90. Effect.gen(function* () {
  91. const auth = yield* Auth.Service
  92. const plugin = yield* Plugin.Service
  93. const state = yield* InstanceState.make<State>(
  94. Effect.fn("ProviderAuth.state")(function* () {
  95. const plugins = yield* plugin.list()
  96. return {
  97. hooks: Record.fromEntries(
  98. Arr.filterMap(plugins, (x) =>
  99. x.auth?.provider !== undefined
  100. ? Result.succeed([ProviderV2.ID.make(x.auth.provider), x.auth] as const)
  101. : Result.failVoid,
  102. ),
  103. ),
  104. pending: new Map<ProviderV2.ID, AuthOAuthResult>(),
  105. }
  106. }),
  107. )
  108. const decode = Schema.decodeUnknownSync(Methods)
  109. const methods = Effect.fn("ProviderAuth.methods")(function* () {
  110. const hooks = (yield* InstanceState.get(state)).hooks
  111. return decode(
  112. Record.map(hooks, (item) =>
  113. item.methods.map((method) => ({
  114. type: method.type,
  115. label: method.label,
  116. ...(method.prompts && {
  117. prompts: method.prompts.map((prompt) => {
  118. if (prompt.type === "select") {
  119. return {
  120. type: "select" as const,
  121. key: prompt.key,
  122. message: prompt.message,
  123. options: prompt.options,
  124. ...(prompt.when && { when: prompt.when }),
  125. }
  126. }
  127. return {
  128. type: "text" as const,
  129. key: prompt.key,
  130. message: prompt.message,
  131. ...(prompt.placeholder && { placeholder: prompt.placeholder }),
  132. ...(prompt.when && { when: prompt.when }),
  133. }
  134. }),
  135. }),
  136. })),
  137. ),
  138. )
  139. })
  140. const authorize = Effect.fn("ProviderAuth.authorize")(function* (
  141. input: { providerID: ProviderV2.ID } & AuthorizeInput,
  142. ) {
  143. const { hooks, pending } = yield* InstanceState.get(state)
  144. const method = hooks[input.providerID].methods[input.method]
  145. if (method.type !== "oauth") return
  146. if (method.prompts && input.inputs) {
  147. for (const prompt of method.prompts) {
  148. if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
  149. const error = prompt.validate(input.inputs[prompt.key])
  150. if (error) return yield* new ValidationFailed({ field: prompt.key, message: error })
  151. }
  152. }
  153. }
  154. const result = yield* Effect.promise(() => method.authorize(input.inputs))
  155. pending.set(input.providerID, result)
  156. return {
  157. url: result.url,
  158. method: result.method,
  159. instructions: result.instructions,
  160. }
  161. })
  162. const callback = Effect.fn("ProviderAuth.callback")(function* (
  163. input: { providerID: ProviderV2.ID } & CallbackInput,
  164. ) {
  165. const pending = (yield* InstanceState.get(state)).pending
  166. const match = pending.get(input.providerID)
  167. if (!match) return yield* new OauthMissing({ providerID: input.providerID })
  168. if (match.method === "code" && !input.code) {
  169. return yield* new OauthCodeMissing({ providerID: input.providerID })
  170. }
  171. const result = yield* Effect.promise(() =>
  172. match.method === "code" ? match.callback(input.code!) : match.callback(),
  173. )
  174. if (!result || result.type !== "success") return yield* new OauthCallbackFailed({})
  175. if ("key" in result) {
  176. yield* auth.set(input.providerID, {
  177. type: "api",
  178. key: result.key,
  179. ...(result.metadata ? { metadata: result.metadata } : {}),
  180. })
  181. }
  182. if ("refresh" in result) {
  183. const { type: _, provider: __, refresh, access, expires, ...extra } = result
  184. yield* auth.set(input.providerID, {
  185. type: "oauth",
  186. access,
  187. refresh,
  188. expires,
  189. ...extra,
  190. })
  191. }
  192. })
  193. return Service.of({ methods, authorize, callback })
  194. }),
  195. )
  196. export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] })
  197. export * as ProviderAuth from "./auth"