|
| 1 | +import { Effect, Layer, Record, ServiceMap, Struct } from "effect" |
| 2 | +import { Instance } from "@/project/instance" |
| 3 | +import { Plugin } from "../plugin" |
| 4 | +import { filter, fromEntries, map, pipe } from "remeda" |
| 5 | +import type { AuthOuathResult } from "@opencode-ai/plugin" |
| 6 | +import { NamedError } from "@opencode-ai/util/error" |
| 7 | +import * as Auth from "@/auth/service" |
| 8 | +import { InstanceState } from "@/util/instance-state" |
| 9 | +import { ProviderID } from "./schema" |
| 10 | +import z from "zod" |
| 11 | + |
| 12 | +export type Method = { |
| 13 | + type: "oauth" | "api" |
| 14 | + label: string |
| 15 | +} |
| 16 | + |
| 17 | +export type Authorization = { |
| 18 | + url: string |
| 19 | + method: "auto" | "code" |
| 20 | + instructions: string |
| 21 | +} |
| 22 | + |
| 23 | +export const OauthMissing = NamedError.create( |
| 24 | + "ProviderAuthOauthMissing", |
| 25 | + z.object({ |
| 26 | + providerID: ProviderID.zod, |
| 27 | + }), |
| 28 | +) |
| 29 | + |
| 30 | +export const OauthCodeMissing = NamedError.create( |
| 31 | + "ProviderAuthOauthCodeMissing", |
| 32 | + z.object({ |
| 33 | + providerID: ProviderID.zod, |
| 34 | + }), |
| 35 | +) |
| 36 | + |
| 37 | +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) |
| 38 | + |
| 39 | +export type ProviderAuthError = |
| 40 | + | Auth.AuthServiceError |
| 41 | + | InstanceType<typeof OauthMissing> |
| 42 | + | InstanceType<typeof OauthCodeMissing> |
| 43 | + | InstanceType<typeof OauthCallbackFailed> |
| 44 | + |
| 45 | +export namespace ProviderAuthService { |
| 46 | + export interface Service { |
| 47 | + /** Get available auth methods for each provider (e.g. OAuth, API key). */ |
| 48 | + readonly methods: () => Effect.Effect<Record<string, Method[]>> |
| 49 | + |
| 50 | + /** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */ |
| 51 | + readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined> |
| 52 | + |
| 53 | + /** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */ |
| 54 | + readonly callback: (input: { |
| 55 | + providerID: ProviderID |
| 56 | + method: number |
| 57 | + code?: string |
| 58 | + }) => Effect.Effect<void, ProviderAuthError> |
| 59 | + |
| 60 | + /** Set an API key directly for a provider (no OAuth flow). */ |
| 61 | + readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError> |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()( |
| 66 | + "@opencode/ProviderAuth", |
| 67 | +) { |
| 68 | + static readonly layer = Layer.effect( |
| 69 | + ProviderAuthService, |
| 70 | + Effect.gen(function* () { |
| 71 | + const auth = yield* Auth.AuthService |
| 72 | + const state = yield* InstanceState.make({ |
| 73 | + lookup: () => |
| 74 | + Effect.promise(async () => { |
| 75 | + const methods = pipe( |
| 76 | + await Plugin.list(), |
| 77 | + filter((x) => x.auth?.provider !== undefined), |
| 78 | + map((x) => [x.auth!.provider, x.auth!] as const), |
| 79 | + fromEntries(), |
| 80 | + ) |
| 81 | + return { methods, pending: new Map<string, AuthOuathResult>() } |
| 82 | + }), |
| 83 | + }) |
| 84 | + |
| 85 | + const methods = Effect.fn("ProviderAuthService.methods")(function* () { |
| 86 | + const x = yield* InstanceState.get(state) |
| 87 | + return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"]))) |
| 88 | + }) |
| 89 | + |
| 90 | + const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { |
| 91 | + providerID: ProviderID |
| 92 | + method: number |
| 93 | + }) { |
| 94 | + const authHook = (yield* InstanceState.get(state)).methods[input.providerID] |
| 95 | + const method = authHook.methods[input.method] |
| 96 | + if (method.type !== "oauth") return |
| 97 | + const result = yield* Effect.promise(() => method.authorize()) |
| 98 | + |
| 99 | + const s = yield* InstanceState.get(state) |
| 100 | + s.pending.set(input.providerID, result) |
| 101 | + return { |
| 102 | + url: result.url, |
| 103 | + method: result.method, |
| 104 | + instructions: result.instructions, |
| 105 | + } |
| 106 | + }) |
| 107 | + |
| 108 | + const callback = Effect.fn("ProviderAuthService.callback")(function* (input: { |
| 109 | + providerID: ProviderID |
| 110 | + method: number |
| 111 | + code?: string |
| 112 | + }) { |
| 113 | + const match = (yield* InstanceState.get(state)).pending.get(input.providerID) |
| 114 | + if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) |
| 115 | + |
| 116 | + if (match.method === "code" && !input.code) |
| 117 | + return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) |
| 118 | + |
| 119 | + const result = yield* Effect.promise(() => |
| 120 | + match.method === "code" ? match.callback(input.code!) : match.callback(), |
| 121 | + ) |
| 122 | + |
| 123 | + if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) |
| 124 | + |
| 125 | + if ("key" in result) { |
| 126 | + yield* auth.set(input.providerID, { |
| 127 | + type: "api", |
| 128 | + key: result.key, |
| 129 | + }) |
| 130 | + } |
| 131 | + |
| 132 | + if ("refresh" in result) { |
| 133 | + yield* auth.set(input.providerID, { |
| 134 | + type: "oauth", |
| 135 | + access: result.access, |
| 136 | + refresh: result.refresh, |
| 137 | + expires: result.expires, |
| 138 | + ...(result.accountId ? { accountId: result.accountId } : {}), |
| 139 | + }) |
| 140 | + } |
| 141 | + }) |
| 142 | + |
| 143 | + const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) { |
| 144 | + yield* auth.set(input.providerID, { |
| 145 | + type: "api", |
| 146 | + key: input.key, |
| 147 | + }) |
| 148 | + }) |
| 149 | + |
| 150 | + return ProviderAuthService.of({ |
| 151 | + methods, |
| 152 | + authorize, |
| 153 | + callback, |
| 154 | + api, |
| 155 | + }) |
| 156 | + }), |
| 157 | + ) |
| 158 | + |
| 159 | + static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer)) |
| 160 | +} |
0 commit comments