feat(core): add connector authentication (#31837)
This commit is contained in:
parent
bf05e8a122
commit
dac0dd5309
47 changed files with 5433 additions and 862 deletions
|
|
@ -1,45 +0,0 @@
|
|||
import { Effect, Scope, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { Auth } from "../auth"
|
||||
|
||||
// Depending on what account is active, enable matching providers for that
|
||||
// service
|
||||
export const AccountPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("account"),
|
||||
effect: Effect.gen(function* () {
|
||||
const accounts = yield* Auth.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
yield* events.subscribe(Auth.Event.Switched).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const active = yield* accounts.activeAll().pipe(Effect.orDie)
|
||||
if (active.size === 0) return
|
||||
for (const item of evt.provider.list()) {
|
||||
const account = active.get(Auth.ServiceID.make(item.provider.id))
|
||||
if (!account) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.enabled = {
|
||||
via: "account",
|
||||
service: account.serviceID,
|
||||
}
|
||||
if (account.credential.type === "api") {
|
||||
provider.request.body.apiKey = account.credential.key
|
||||
Object.assign(provider.request.body, account.credential.metadata ?? {})
|
||||
}
|
||||
if (account.credential.type === "oauth") provider.request.body.apiKey = account.credential.access
|
||||
})
|
||||
}
|
||||
}),
|
||||
"account.switched": Effect.fn(function* () {}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -79,7 +79,7 @@ Your output must be:
|
|||
"implement rate limiting" -> Rate limiting implementation
|
||||
"how do I connect postgres to my API" -> Postgres API connection
|
||||
"best practices for React hooks" -> React hooks best practices
|
||||
"@src/auth.ts can you add refresh token support" -> Auth refresh token support
|
||||
"@src/credential.ts can you add refresh token support" -> Credential refresh token support
|
||||
"@utils/parser.ts this is broken" -> Parser bug fix
|
||||
"look at @config.json" -> Config review
|
||||
"@App.tsx add dark mode toggle" -> Dark mode toggle in App
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export * as PluginBoot from "./boot"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { Auth } from "../auth"
|
||||
import { Credential } from "../credential"
|
||||
import { Connector } from "../connector"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
|
|
@ -17,7 +18,6 @@ import { Location } from "../location"
|
|||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AccountPlugin } from "./account"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { SkillPlugin } from "./skill"
|
||||
|
|
@ -33,7 +33,8 @@ type Plugin = {
|
|||
effect: PluginV2.Effect<
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Auth.Service
|
||||
| Credential.Service
|
||||
| Connector.Service
|
||||
| AgentV2.Service
|
||||
| Npm.Service
|
||||
| EventV2.Service
|
||||
|
|
@ -60,7 +61,8 @@ export const layer = Layer.effect(
|
|||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* Auth.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const connectors = yield* Connector.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
|
|
@ -79,7 +81,8 @@ export const layer = Layer.effect(
|
|||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Auth.Service, accounts),
|
||||
Effect.provideService(Credential.Service, credentials),
|
||||
Effect.provideService(Connector.Service, connectors),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
|
|
@ -97,7 +100,6 @@ export const layer = Layer.effect(
|
|||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AccountPlugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
|
|
@ -125,6 +127,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Connector.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Connector } from "../connector"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelRequest } from "../model-request"
|
||||
|
|
@ -54,12 +56,30 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
id: PluginV2.ID.make("models-dev"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const connectors = yield* Connector.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const transform = yield* catalog.transform()
|
||||
const connectorTransform = yield* connectors.transform()
|
||||
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
||||
const data = yield* modelsDev.get()
|
||||
yield* connectorTransform((connectors) => {
|
||||
for (const item of Object.values(data)) {
|
||||
if (item.env.length === 0) continue
|
||||
const connectorID = Connector.ID.make(item.id)
|
||||
connectors.update(connectorID, (connector) => (connector.name = item.name))
|
||||
connectors.method.update({
|
||||
connectorID,
|
||||
method: new Connector.KeyMethod({
|
||||
id: Connector.MethodID.make("api-key"),
|
||||
type: "key",
|
||||
label: "API Key",
|
||||
}),
|
||||
authorize: (key: string) => Effect.succeed(new Credential.Key({ type: "key", key })),
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* transform((catalog) => {
|
||||
for (const item of Object.values(data)) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
|||
|
||||
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
// AccountPlugin copies CLI prompt metadata into options. The prompt stores the
|
||||
// Credential projection copies key metadata into options. The prompt stores the
|
||||
// gateway as gatewayId, while older config examples may use gateway.
|
||||
const gatewayId =
|
||||
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
|
||||
|
|
|
|||
|
|
@ -24,9 +24,12 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
|||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
if (!hasWorkersEndpoint(evt.model.api)) return
|
||||
const accountId = resolveAccountId(evt.options)
|
||||
if (!hasWorkersEndpoint(evt.model.api) && !accountId) return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any)
|
||||
evt.sdk = mod.createOpenAICompatible(
|
||||
sdkOptions({ ...evt.options, baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined) }) as any,
|
||||
)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
|
|
|
|||
251
packages/core/src/plugin/provider/openai-auth.ts
Normal file
251
packages/core/src/plugin/provider/openai-auth.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Connector } from "../../connector"
|
||||
import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const issuer = "https://auth.openai.com"
|
||||
const callbackPort = 1455
|
||||
const pollingSafetyMargin = 3000
|
||||
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
type TokenResponse = {
|
||||
id_token: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
type Claims = {
|
||||
chatgpt_account_id?: string
|
||||
organizations?: Array<{ id: string }>
|
||||
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
|
||||
}
|
||||
|
||||
export const browser = {
|
||||
connectorID: Connector.ID.make("openai"),
|
||||
method: new Connector.OAuthMethod({
|
||||
id: Connector.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
server.close()
|
||||
}),
|
||||
)
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: authorizeURL(redirect, pkce, state),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
||||
Effect.map(credential),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(value),
|
||||
} satisfies Connector.OAuthImplementation
|
||||
|
||||
export const headless = {
|
||||
connectorID: Connector.ID.make("openai"),
|
||||
method: new Connector.OAuthMethod({
|
||||
id: Connector.MethodID.make("chatgpt-headless"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||
`${issuer}/api/accounts/deviceauth/usercode`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: headers("application/json"),
|
||||
body: JSON.stringify({ client_id: clientID }),
|
||||
},
|
||||
)
|
||||
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${issuer}/codex/device`,
|
||||
instructions: `Enter code: ${device.user_code}`,
|
||||
callback: Effect.gen(function* () {
|
||||
while (true) {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
||||
method: "POST",
|
||||
headers: headers("application/json"),
|
||||
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
||||
signal,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = (yield* Effect.promise(() => response.json())) as {
|
||||
authorization_code: string
|
||||
code_verifier: string
|
||||
}
|
||||
return credential(
|
||||
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
||||
verifier: data.code_verifier,
|
||||
challenge: "",
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (response.status !== 403 && response.status !== 404) {
|
||||
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
|
||||
}
|
||||
yield* Effect.sleep(interval + pollingSafetyMargin)
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(value),
|
||||
} satisfies Connector.OAuthImplementation
|
||||
|
||||
function headers(contentType: string) {
|
||||
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
||||
}
|
||||
|
||||
function exchange(code: string, redirect: string, pkce: Pkce) {
|
||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: headers("application/x-www-form-urlencoded"),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirect,
|
||||
client_id: clientID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
function refresh(value: Credential.OAuth) {
|
||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: headers("application/x-www-form-urlencoded"),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: value.refresh,
|
||||
client_id: clientID,
|
||||
}).toString(),
|
||||
}).pipe(
|
||||
Effect.map((tokens) => {
|
||||
const next = credential(tokens)
|
||||
return new Credential.OAuth({
|
||||
...next,
|
||||
metadata: next.metadata ?? value.metadata,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function request<A>(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(url, { ...init, signal })
|
||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
||||
return response.json() as Promise<A>
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: TokenResponse) {
|
||||
const accountID = extractAccountID(tokens)
|
||||
return new Credential.OAuth({
|
||||
type: "oauth",
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
metadata: accountID ? { accountID } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<Pkce> {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||
return Buffer.from(buffer).toString("base64url")
|
||||
}
|
||||
|
||||
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
||||
return `${issuer}/oauth/authorize?${new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientID,
|
||||
redirect_uri: redirect,
|
||||
scope: "openid profile email offline_access",
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
state,
|
||||
originator: "opencode",
|
||||
})}`
|
||||
}
|
||||
|
||||
function extractAccountID(tokens: TokenResponse) {
|
||||
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
||||
}
|
||||
|
||||
function claim(token: string) {
|
||||
const part = token.split(".")[1]
|
||||
if (!part) return
|
||||
try {
|
||||
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
||||
return (
|
||||
claims.chatgpt_account_id ??
|
||||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
|
||||
claims.organizations?.[0]?.id
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const successPage = "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
||||
const errorPage = (message: string) =>
|
||||
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
||||
|
|
@ -2,10 +2,17 @@ import { Effect } from "effect"
|
|||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { Connector } from "../../connector"
|
||||
import { browser, headless } from "./openai-auth"
|
||||
|
||||
export const OpenAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai"),
|
||||
effect: Effect.gen(function* () {
|
||||
const connectors = yield* Connector.Service
|
||||
yield* connectors.update((editor) => {
|
||||
editor.method.update(browser)
|
||||
editor.method.update(headless)
|
||||
})
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const OpencodePlugin = PluginV2.define({
|
|||
process.env.OPENCODE_API_KEY ||
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.request.body.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "account"),
|
||||
(item.provider.enabled && item.provider.enabled.via === "credential"),
|
||||
)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue