175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
import path from "path"
|
|
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { Effect, Layer, Context, Option, Schema } from "effect"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
|
|
|
export const Tokens = Schema.Struct({
|
|
accessToken: Schema.mutableKey(Schema.String),
|
|
refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
|
|
expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
|
scope: Schema.mutableKey(Schema.optional(Schema.String)),
|
|
})
|
|
export type Tokens = Schema.Schema.Type<typeof Tokens>
|
|
|
|
export const ClientInfo = Schema.Struct({
|
|
clientId: Schema.mutableKey(Schema.String),
|
|
clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
|
|
clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
|
clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
|
})
|
|
export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
|
|
|
|
export const Entry = Schema.Struct({
|
|
tokens: Schema.mutableKey(Schema.optional(Tokens)),
|
|
clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)),
|
|
codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)),
|
|
oauthState: Schema.mutableKey(Schema.optional(Schema.String)),
|
|
serverUrl: Schema.mutableKey(Schema.optional(Schema.String)),
|
|
})
|
|
export type Entry = Schema.Schema.Type<typeof Entry>
|
|
|
|
const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
|
|
type AuthData = Record<string, Entry>
|
|
|
|
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
|
const lockKey = `mcp-auth:${filepath}`
|
|
|
|
export interface Interface {
|
|
readonly all: () => Effect.Effect<Record<string, Entry>>
|
|
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
|
|
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
|
|
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
|
|
readonly remove: (mcpName: string) => Effect.Effect<void>
|
|
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
|
|
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
|
|
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
|
|
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
|
|
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
|
|
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
|
|
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
|
|
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
|
|
|
|
export const use = serviceUse(Service)
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* AppFileSystem.Service
|
|
const flock = yield* EffectFlock.Service
|
|
|
|
const read = Effect.fn("McpAuth.read")(function* () {
|
|
return yield* fs.readJson(filepath).pipe(
|
|
Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData),
|
|
Effect.catch(() => Effect.succeed({} as AuthData)),
|
|
)
|
|
})
|
|
|
|
const all = Effect.fn("McpAuth.all")(function* () {
|
|
return yield* read().pipe(flock.withLock(lockKey), Effect.orDie)
|
|
})
|
|
|
|
const mutate = Effect.fn("McpAuth.mutate")(function* (update: (data: AuthData) => AuthData | undefined) {
|
|
yield* Effect.gen(function* () {
|
|
const next = update(yield* read())
|
|
if (!next) return
|
|
yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie)
|
|
}).pipe(flock.withLock(lockKey), Effect.orDie)
|
|
})
|
|
|
|
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
|
|
const data = yield* all()
|
|
return data[mcpName]
|
|
})
|
|
|
|
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
|
|
const entry = yield* get(mcpName)
|
|
if (!entry) return undefined
|
|
if (!entry.serverUrl) return undefined
|
|
if (entry.serverUrl !== serverUrl) return undefined
|
|
return entry
|
|
})
|
|
|
|
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
|
|
yield* mutate((data) => ({
|
|
...data,
|
|
[mcpName]: serverUrl ? { ...entry, serverUrl } : entry,
|
|
}))
|
|
})
|
|
|
|
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
|
|
yield* mutate((data) => {
|
|
const next = { ...data }
|
|
delete next[mcpName]
|
|
return next
|
|
})
|
|
})
|
|
|
|
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
|
|
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
|
|
yield* mutate((data) => {
|
|
const entry = data[mcpName] ?? {}
|
|
entry[field] = value
|
|
if (serverUrl) entry.serverUrl = serverUrl
|
|
return { ...data, [mcpName]: entry }
|
|
})
|
|
})
|
|
|
|
const clearField = (field: keyof Entry, spanName: string) =>
|
|
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
|
|
yield* mutate((data) => {
|
|
const entry = data[mcpName]
|
|
if (!entry) return undefined
|
|
delete entry[field]
|
|
return { ...data, [mcpName]: entry }
|
|
})
|
|
})
|
|
|
|
const updateTokens = updateField("tokens", "updateTokens")
|
|
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
|
|
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
|
|
const updateOAuthState = updateField("oauthState", "updateOAuthState")
|
|
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
|
|
const clearOAuthState = clearField("oauthState", "clearOAuthState")
|
|
|
|
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
|
|
const entry = yield* get(mcpName)
|
|
return entry?.oauthState
|
|
})
|
|
|
|
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
|
|
const entry = yield* get(mcpName)
|
|
if (!entry?.tokens) return null
|
|
if (!entry.tokens.expiresAt) return false
|
|
return entry.tokens.expiresAt < Date.now() / 1000
|
|
})
|
|
|
|
return Service.of({
|
|
all,
|
|
get,
|
|
getForUrl,
|
|
set,
|
|
remove,
|
|
updateTokens,
|
|
updateClientInfo,
|
|
updateCodeVerifier,
|
|
clearCodeVerifier,
|
|
updateOAuthState,
|
|
getOAuthState,
|
|
clearOAuthState,
|
|
isTokenExpired,
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(
|
|
Layer.provide(EffectFlock.defaultLayer),
|
|
Layer.provide(AppFileSystem.defaultLayer),
|
|
)
|
|
|
|
export * as McpAuth from "./auth"
|