diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3c80f44505d3..482d92045fd7 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -29,9 +29,9 @@ import type { Form } from "@opencode/schema/form" import type { Generate } from "@opencode/schema/generate" import type { Provider } from "@opencode/schema/provider" import type { Integration } from "@opencode/schema/integration" +import type { Credential } from "@opencode/schema/credential" import type { Mcp } from "@opencode/schema/mcp" import type { PtyTicket } from "@opencode/schema/pty-ticket" -import type { Credential } from "@opencode/schema/credential" import type { PermissionSaved } from "@opencode/schema/permission-saved" import type { FileSystem } from "@opencode/schema/filesystem" import type { Command } from "@opencode/schema/command" @@ -1554,6 +1554,7 @@ export type IntegrationOauthConnectInput = { readonly integrationID: Integration.ID readonly location?: { readonly directory?: string | undefined } | undefined readonly methodID: Integration.MethodID + readonly credentialID?: Credential.ID | undefined readonly answer?: Form.Answer | undefined readonly label?: string | undefined } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a7d53fd2b025..5519bff4a757 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1018,7 +1018,12 @@ const EndpointIntegrationOauthConnect = raw["integration.oauth.connect"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { methodID: input["methodID"], answer: input["answer"], label: input["label"] }, + payload: { + methodID: input["methodID"], + credentialID: input["credentialID"], + answer: input["answer"], + label: input["label"], + }, }).pipe(Effect.mapError(mapClientError)), ) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 32aec4b71f19..df7ce3982b2e 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1350,7 +1350,12 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, query: { location: input["location"] }, - body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] }, + body: { + methodID: input["methodID"], + credentialID: input["credentialID"], + answer: input["answer"], + label: input["label"], + }, successStatus: 200, declaredStatuses: [400, 401, 404], empty: false, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index f5db7ce7e535..a71a641955dd 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -3850,6 +3850,7 @@ export type IntegrationOauthConnectInput = { readonly integrationID: string readonly location?: { readonly directory?: string | undefined } | undefined readonly methodID: string + readonly credentialID?: string | undefined readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined readonly label?: string | undefined } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 824d2975abd1..fda1eee8358c 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -75,7 +75,10 @@ export type OAuthAuthorization = { export interface OAuthImplementation { readonly integrationID: ID readonly method: OAuthMethod - readonly authorize: (answer: Form.Answer) => Effect.Effect + readonly authorize: ( + answer: Form.Answer, + context?: { readonly credential?: Credential.OAuth }, + ) => Effect.Effect readonly refresh?: (credential: Credential.OAuth) => Effect.Effect readonly label?: (credential: Credential.OAuth) => string | undefined } @@ -225,6 +228,7 @@ export interface Interface extends State.Transformable { readonly connect: (input: { readonly integrationID: ID readonly methodID: MethodID + readonly credentialID?: Credential.ID readonly answer?: Form.Answer readonly label?: string }) => Effect.Effect @@ -607,6 +611,7 @@ const layer = Layer.effect( const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: { readonly integrationID: ID readonly methodID: MethodID + readonly credentialID?: Credential.ID readonly answer?: Form.Answer readonly label?: string }) { @@ -614,13 +619,26 @@ const layer = Layer.effect( if (!method) { return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) } + const credential = yield* Effect.gen(function* () { + if (input.credentialID === undefined) return undefined + // Reauthorization uses the requested stored value without resolving or refreshing it. + const saved = yield* credentials.get(input.credentialID) + if (!saved) return yield* new AuthorizationError({ cause: new Error("OAuth credential not found") }) + if (saved.integrationID !== input.integrationID) + return yield* new AuthorizationError({ cause: new Error("OAuth credential belongs to another integration") }) + if (saved.value.type !== "oauth") + return yield* new AuthorizationError({ cause: new Error("Credential is not an OAuth credential") }) + // Choosing another OAuth method starts a new connection without reusing the old registration. + if (saved.value.methodID !== input.methodID) return undefined + return saved.value + }) const answer = input.answer ?? {} if (method.method.form) { const invalid = Form.validateFields(method.method.form) ?? Form.validateAnswer(method.method.form, answer) if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) }) } const attemptScope = yield* Scope.fork(scope) - const authorization = yield* authorize(method.authorize(answer)).pipe( + const authorization = yield* authorize(method.authorize(answer, credential ? { credential } : undefined)).pipe( Scope.provide(attemptScope), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), ) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 4485a0ab7736..cc867c9971c3 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -321,6 +321,7 @@ export const make = Effect.fn("PluginHost.make")(function* ( integration.oauth.connect({ integrationID: Integration.ID.make(input.integrationID), methodID: Integration.MethodID.make(input.methodID), + credentialID: input.credentialID === undefined ? undefined : Credential.ID.make(input.credentialID), answer: input.answer, label: input.label, }), @@ -692,8 +693,8 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration return { integrationID: Integration.ID.make(input.integrationID), method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, - authorize: (answer) => - input.authorize(answer).pipe( + authorize: (answer, context) => + input.authorize(answer, context).pipe( Effect.map((authorization) => { if (authorization.mode === "auto") { return { diff --git a/packages/core/src/plugin/provider/chatgpt.ts b/packages/core/src/plugin/provider/chatgpt.ts index 018c275d9c43..4d21f1b40103 100644 --- a/packages/core/src/plugin/provider/chatgpt.ts +++ b/packages/core/src/plugin/provider/chatgpt.ts @@ -92,7 +92,7 @@ const decodeMetadata = Schema.decodeUnknownOption( }), ) -const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) => +const signIn = (app: App.Info, storage: Context["storage"]) => ({ integrationID, method: { @@ -100,15 +100,19 @@ const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: type: "oauth", label: "Sign in with ChatGPT", }, - authorize: () => + authorize: (_answer, context) => Effect.gen(function* () { + const savedID = context?.credential + ? Option.getOrUndefined(decodeMetadata(context.credential.metadata))?.clientID + : undefined + if (context?.credential && !savedID?.trim()) + return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again.")) const storedHostID = yield* storage.get("chatgpt-agent-host-id") const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}` if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID) const pkce = yield* Effect.promise(generatePKCE) const state = randomValue() const nonce = randomValue() - const savedID = savedClientID() const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>() // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. const { createServer } = yield* Effect.promise(() => import("node:http")) @@ -234,9 +238,7 @@ export const ChatGPTPlugin = define({ }) yield* ctx.integration.transform((editor) => { - editor.method.update( - signIn(ctx.app, () => Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))?.clientID, ctx.storage), - ) + editor.method.update(signIn(ctx.app, ctx.storage)) }) yield* Effect.forEach( yield* credentials.list(Integration.ID.make("openai")), diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 9a2f77496997..aba2e9125250 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -280,8 +280,9 @@ describe("Integration", () => { editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, - authorize: () => - Effect.succeed({ + authorize: (answer) => { + expect(answer).toEqual({}) + return Effect.succeed({ mode: "code" as const, url: "https://example.com/authorize", instructions: "Paste the code", @@ -296,7 +297,8 @@ describe("Integration", () => { metadata: { code }, }), ), - }), + }) + }, }), ) @@ -325,6 +327,114 @@ describe("Integration", () => { }), ) + for (const target of ["missing", "other-integration", "key"] as const) { + it.effect(`rejects an OAuth ${target} target before authorization without changing credentials`, () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + const methodID = Integration.MethodID.make("chatgpt") + let authorizations = 0 + yield* integrations.transform((editor) => + editor.method.update({ + integrationID, + method: { id: methodID, type: "oauth", label: "ChatGPT" }, + authorize: () => { + authorizations++ + return Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: () => Effect.die("unexpected callback"), + }) + }, + }), + ) + const credential = yield* credentials.create({ + integrationID: target === "other-integration" ? Integration.ID.make("other") : integrationID, + value: + target === "key" + ? Credential.Key.make({ type: "key", key: "secret" }) + : Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + }), + }) + const before = yield* credentials.all() + const active = yield* integrations.connection.active(integrationID) + + expect( + yield* integrations.oauth + .connect({ + integrationID, + methodID, + credentialID: target === "missing" ? Credential.ID.make("cred_missing") : credential.id, + }) + .pipe(Effect.flip), + ).toBeInstanceOf(Integration.AuthorizationError) + expect(authorizations).toBe(0) + expect(yield* credentials.all()).toEqual(before) + expect(yield* integrations.connection.active(integrationID)).toEqual(active) + }), + ) + } + + for (const sameMethod of [true, false]) { + it.effect(`authorizes an OAuth target with ${sameMethod ? "the same" : "a different"} method`, () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + const value = Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("original"), + access: "old-access", + refresh: "old-refresh", + expires: 1, + }) + const target = yield* credentials.create({ integrationID, value }) + const methodID = sameMethod ? value.methodID : Integration.MethodID.make("selected") + const answer = { account: "work" } + yield* integrations.transform((editor) => + editor.method.update({ + integrationID, + method: { + id: methodID, + type: "oauth", + label: "Selected", + form: [{ type: "string", key: "account", title: "Account", required: true }], + }, + authorize: (received, context) => { + expect(received).toEqual(answer) + expect(context?.credential).toEqual(sameMethod ? value : undefined) + return Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: (code: string) => Effect.succeed(Credential.OAuth.make({ ...value, methodID, access: code })), + }) + }, + }), + ) + const before = yield* credentials.list(integrationID) + const active = yield* integrations.connection.active(integrationID) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, credentialID: target.id, answer }) + expect(yield* credentials.list(integrationID)).toEqual(before) + expect(yield* integrations.connection.active(integrationID)).toEqual(active) + + yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID, code: "new-access" }) + const after = yield* credentials.list(integrationID) + expect(after.find((entry) => entry.id === target.id)?.value).toEqual(value) + expect(after.filter((entry) => entry.id !== target.id)).toEqual([ + expect.objectContaining({ value: Credential.OAuth.make({ ...value, methodID, access: "new-access" }) }), + ]) + }), + ) + } + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 74d2899d0581..7eb2ef02bfb0 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -366,8 +366,8 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont editor.method.update({ integrationID: Integration.ID.make(input.integrationID), method: { ...input.method, id: methodID }, - authorize: (answer) => - input.authorize(answer).pipe( + authorize: (answer, context) => + input.authorize(answer, context).pipe( Effect.map((authorization) => { if (authorization.mode === "auto") { return { diff --git a/packages/core/test/plugin/provider-chatgpt.test.ts b/packages/core/test/plugin/provider-chatgpt.test.ts index 46fc786a53df..797253237267 100644 --- a/packages/core/test/plugin/provider-chatgpt.test.ts +++ b/packages/core/test/plugin/provider-chatgpt.test.ts @@ -63,12 +63,13 @@ function required(value: T | undefined): T { return value } -const authorize = Effect.fn(function* () { +const authorize = Effect.fn(function* (credentialID?: Credential.ID) { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const attempt = yield* integrations.oauth.connect({ integrationID, methodID: Integration.MethodID.make("chatgpt-token-sharing"), + credentialID, }) yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID }) return new URL(attempt.url) @@ -392,7 +393,7 @@ describe("ChatGPTPlugin", () => { }), ) - it.effect("registers a new ChatGPT agent on first sign-in with a loopback callback", () => + it.live("registers a new ChatGPT agent even when another account is already connected", () => Effect.gen(function* () { yield* addPlugin() const url = yield* authorize() @@ -414,7 +415,24 @@ describe("ChatGPTPlugin", () => { const kv = yield* KV.Service const storage = PluginHost.storage(kv, ChatGPTPlugin.id) expect(yield* storage.get("chatgpt-agent-host-id")).toBe(hostID) - expect((yield* authorize()).searchParams.get("ext_agent_host_id")).toBe(hostID) + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "existing-access", + refresh: "existing-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_existing" }, + }), + }) + const next = yield* authorize() + expect(Object.fromEntries(next.searchParams)).toMatchObject({ + client_id: "dynamic_agent_client", + agent_name_hint: "OpenCode", + ext_agent_host_id: hostID, + }) expect(redirect.hostname).toBe("127.0.0.1") expect(redirect.pathname).toBe("/auth/callback") expect(Number(redirect.port)).toBeGreaterThan(0) @@ -439,13 +457,13 @@ describe("ChatGPTPlugin", () => { }), ) - it.effect("reauthorizes with the client ID issued to an existing ChatGPT connection", () => + it.live("reauthorizes with the client ID issued to an existing ChatGPT connection", () => Effect.gen(function* () { const credentials = yield* Credential.Service const kv = yield* KV.Service const hostID = "urn:uuid:00000000-0000-4000-8000-000000000000" yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("chatgpt-agent-host-id", hostID) - yield* credentials.create({ + const credential = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", @@ -457,7 +475,7 @@ describe("ChatGPTPlugin", () => { }), }) yield* addPlugin() - const url = yield* authorize() + const url = yield* authorize(credential.id) expect(url.searchParams.get("client_id")).toBe("oaiapp_issued") expect(url.searchParams.has("agent_name_hint")).toBe(false) expect(url.searchParams.get("ext_agent_host_id")).toBe(hostID) @@ -465,10 +483,104 @@ describe("ChatGPTPlugin", () => { }), ) - it.live("rejects a reauthorization callback that returns a different client ID", () => + it.live("registers a new ChatGPT agent when switching a legacy target to token sharing", () => Effect.gen(function* () { const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + const legacy = yield* credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { accountID: "acct_legacy" }, + }), + }) yield* credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "active-access", + refresh: "active-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_active" }, + }), + }) + yield* addPlugin() + const url = yield* authorize(legacy.id) + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: "dynamic_agent_client", + agent_name_hint: "OpenCode", + }) + }), + ) + + it.live("reauthorizes a non-active ChatGPT connection instead of the active one", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const accounts = yield* Effect.forEach(["a", "b"], (account) => + credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: `access-${account}`, + refresh: `refresh-${account}`, + expires: Date.now() + 60 * 60_000, + metadata: { clientID: `oaiapp_${account}` }, + }), + }), + ) + const a = required(accounts[0]) + const b = required(accounts[1]) + yield* credentials.activate(a.id) + yield* addPlugin() + expect(yield* integrations.connection.active(integrationID)).toMatchObject({ id: a.id }) + const before = yield* credentials.list(integrationID) + + const url = yield* authorize(b.id) + expect(url.searchParams.get("client_id")).toBe("oaiapp_b") + expect(url.searchParams.has("agent_name_hint")).toBe(false) + expect(yield* credentials.list(integrationID)).toEqual(before) + expect(yield* integrations.connection.active(integrationID)).toMatchObject({ id: a.id }) + }), + ) + + for (const metadata of [undefined, {}, { clientID: "" }, { clientID: " " }]) { + it.effect(`rejects a ChatGPT reauthorization target with invalid client metadata ${JSON.stringify(metadata)}`, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const credential = yield* credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "access", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata, + }), + }) + yield* addPlugin() + const before = yield* credentials.list(integrationID) + expect(yield* authorize(credential.id).pipe(Effect.flip)).toBeInstanceOf(Integration.AuthorizationError) + expect(yield* credentials.list(integrationID)).toEqual(before) + expect(yield* integrations.connection.active(integrationID)).toMatchObject({ id: credential.id }) + }), + ) + } + + it.live("rejects a different-client callback even after the active ChatGPT connection changes", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const credential = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value: Credential.OAuth.make({ type: "oauth", @@ -479,14 +591,29 @@ describe("ChatGPTPlugin", () => { metadata: { clientID: "oaiapp_issued" }, }), }) + const other = yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + activate: false, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "other-access", + refresh: "other-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_other" }, + }), + }) yield* addPlugin() const integrations = yield* Integration.Service const attempt = yield* integrations.oauth.connect({ integrationID: Integration.ID.make("openai"), methodID: Integration.MethodID.make("chatgpt-token-sharing"), + credentialID: credential.id, }) const authorization = new URL(attempt.url) expect(authorization.searchParams.get("client_id")).toBe("oaiapp_issued") + yield* credentials.activate(other.id) + const before = yield* credentials.list(Integration.ID.make("openai")) const callback = new URL(authorization.searchParams.get("redirect_uri") ?? "") callback.searchParams.set("code", "test-code") callback.searchParams.set("state", authorization.searchParams.get("state") ?? "") @@ -494,7 +621,7 @@ describe("ChatGPTPlugin", () => { const response = yield* Effect.promise(() => fetch(callback)) expect(response.status).toBe(400) expect(yield* Effect.promise(() => response.text())).toContain("different client") - expect(yield* credentials.list(Integration.ID.make("openai"))).toHaveLength(1) + expect(yield* credentials.list(Integration.ID.make("openai"))).toEqual(before) }), ) diff --git a/packages/plugin/src/effect/integration.ts b/packages/plugin/src/effect/integration.ts index dea0f616781b..a7a35622159b 100644 --- a/packages/plugin/src/effect/integration.ts +++ b/packages/plugin/src/effect/integration.ts @@ -64,7 +64,10 @@ export type IntegrationOAuthAuthorization = { export type IntegrationOAuthMethodRegistration = { readonly integrationID: string readonly method: IntegrationOAuthMethod - readonly authorize: (answer: Form.Answer) => Effect.Effect + readonly authorize: ( + answer: Form.Answer, + context?: { readonly credential?: Credential.OAuth }, + ) => Effect.Effect readonly refresh?: (credential: Credential.OAuth) => Effect.Effect readonly label?: (credential: Credential.OAuth) => string | undefined } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index d3932b50a5c5..1c530d9247e5 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -416,8 +416,8 @@ export function fromPromise(plugin: Plugin) { const refresh = input.refresh editor.method.update({ ...input, - authorize: (answer) => - Effect.promise(() => input.authorize(answer)).pipe( + authorize: (answer, context) => + Effect.promise(() => input.authorize(answer, context)).pipe( Effect.map((authorization) => authorization.mode === "auto" ? { diff --git a/packages/plugin/src/promise/integration.ts b/packages/plugin/src/promise/integration.ts index 630b391826a6..ae40cec2755d 100644 --- a/packages/plugin/src/promise/integration.ts +++ b/packages/plugin/src/promise/integration.ts @@ -64,7 +64,10 @@ export type IntegrationOAuthAuthorization = { export type IntegrationOAuthMethodRegistration = { readonly integrationID: string readonly method: IntegrationOAuthMethod - readonly authorize: (answer: Form.Answer) => Promise + readonly authorize: ( + answer: Form.Answer, + context?: { readonly credential?: Credential.OAuth }, + ) => Promise readonly refresh?: (credential: Credential.OAuth) => Promise readonly label?: (credential: Credential.OAuth) => string | undefined } diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 1789c459277b..e3bb6d990977 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -6853,6 +6853,16 @@ "methodID": { "$ref": "#/components/schemas/Integration.MethodID" }, + "credentialID": { + "anyOf": [ + { + "$ref": "#/components/schemas/Credential.ID" + }, + { + "type": "null" + } + ] + }, "answer": { "anyOf": [ { diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index 49b0c78206de..d30ddafe0cf9 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -1,4 +1,5 @@ import { Integration } from "@opencode/schema/integration" +import { Credential } from "@opencode/schema/credential" import { Location } from "@opencode/schema/location" import { Form } from "@opencode/schema/form" import { Schema } from "effect" @@ -106,6 +107,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") query: LocationQuery, payload: Schema.Struct({ methodID: Integration.MethodID, + credentialID: Schema.optional(Credential.ID), answer: Schema.optional(Form.Answer), label: Schema.optional(Schema.String), }), diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index dfdbaa447620..ed1d254ed47f 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -113,6 +113,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" service.oauth.connect({ integrationID: ctx.params.integrationID, methodID: ctx.payload.methodID, + credentialID: ctx.payload.credentialID, answer: ctx.payload.answer, label: ctx.payload.label, }), diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 56f8a1dbf964..af7458e6b383 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -166,7 +166,10 @@ function occupy(port: number, cancel = false) { }) } -const connectOpenAI = (handler: Handler) => +const connectOpenAI = ( + handler: Handler, + input: { methodID: string; credentialID?: string } = { methodID: "chatgpt-browser" }, +) => Effect.gen(function* () { // Catalog endpoints no longer wait for plugin initialization. yield* Effect.gen(function* () { @@ -178,7 +181,7 @@ const connectOpenAI = (handler: Handler) => return body.data.some( (integration) => integration.id === "openai" && - integration.methods.some((method) => method.type === "oauth" && method.id === "chatgpt-browser"), + integration.methods.some((method) => method.type === "oauth" && method.id === input.methodID), ) }).pipe( Effect.filterOrFail((ready) => ready), @@ -190,7 +193,7 @@ const connectOpenAI = (handler: Handler) => new Request("http://opencode.local/api/integration/openai/connect/oauth", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ methodID: "chatgpt-browser" }), + body: JSON.stringify(input), }), ), ) @@ -230,6 +233,51 @@ it.live("activates credentials through the HttpApi", () => }), ) +it.live("forwards the OAuth credentialID from the HttpApi payload to ChatGPT authorization", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make(options) + const created = yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/credential", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "cred_wire_target", + integrationID: "openai", + value: { + type: "oauth", + methodID: "chatgpt-token-sharing", + access: "wire-access", + refresh: "wire-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_wire_target" }, + }, + }), + }), + ), + ) + expect(created.status).toBe(200) + + const response = yield* connectOpenAI(handler, { + methodID: "chatgpt-token-sharing", + credentialID: "cred_wire_target", + }) + expect(response.status).toBe(200) + const body = Schema.decodeUnknownSync(Schema.Struct({ data: Integration.Attempt }))( + yield* Effect.promise(() => response.json()), + ) + expect(new URL(body.data.url).searchParams.get("client_id")).toBe("oaiapp_wire_target") + const cancelled = yield* Effect.promise(() => + handler( + new Request(`http://opencode.local/api/integration/openai/connect/oauth/${body.data.attemptID}`, { + method: "DELETE", + }), + ), + ) + expect(cancelled.status).toBe(204) + }), +) + it.live("serves unauthenticated and answers CORS preflight when no password is configured", () => Effect.gen(function* () { const handler = yield* ServerFetch.make(options) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 17a5143af19b..434f23e37a37 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -226,7 +226,14 @@ function manageConnections( onSelect: () => { if (connection.status?.url) return void openUrl(connection.status.url).catch(toast.error) if (connection.status) - return selectMethod(current() ?? integration, methods, location, dialog, onConnected) + return selectMethod( + current() ?? integration, + methods, + location, + dialog, + onConnected, + connection.method === "oauth" ? connection.id : undefined, + ) if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error) }, @@ -293,8 +300,9 @@ function selectMethod( location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, + credentialID?: string, ) { - if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected) + if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected, credentialID) dialog.replace(() => { const newDesigns = useNewDesigns() return ( @@ -304,7 +312,7 @@ function selectMethod( options={methods.map((method) => ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, - onSelect: () => openMethod(integration, method, location, dialog, onConnected), + onSelect: () => openMethod(integration, method, location, dialog, onConnected, credentialID), }))} /> ) @@ -317,6 +325,7 @@ function openMethod( location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, + credentialID?: string, ) { if (method.type === "key") { void beginKey(integration, method, location, dialog, onConnected) @@ -332,7 +341,7 @@ function openMethod( void beginExternal(integration, method, location, dialog, onConnected) return } - void beginOAuth(integration, method, location, dialog, onConnected) + void beginOAuth(integration, method, location, dialog, onConnected, credentialID) } async function beginExternal( @@ -587,6 +596,7 @@ async function beginOAuth( location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, + credentialID?: string, ) { const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined if (answer === null) return @@ -595,6 +605,7 @@ async function beginOAuth( integration={integration} method={method} location={location} + credentialID={credentialID} answer={answer} onConnected={onConnected} /> @@ -605,6 +616,7 @@ function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod location: LocationRef + credentialID?: string answer?: FormAnswer onConnected?: OnIntegrationConnected }) { @@ -618,6 +630,7 @@ function OAuthStarting(props: { integrationID: props.integration.id, location: locationQuery(props.location), methodID: props.method.id, + ...(props.credentialID !== undefined ? { credentialID: props.credentialID } : {}), ...(props.answer ? { answer: props.answer } : {}), }) .then((result) => { diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx index 744ec506f784..970cb202ba87 100644 --- a/packages/tui/test/cli/tui/dialog-integration.test.tsx +++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { InputRenderable, TextareaRenderable } from "@opentui/core" -import type { FormFields, LocationRef } from "@opencode/client" +import type { ConnectionCredentialInfo, FormFields, LocationRef } from "@opencode/client" import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { onMount } from "solid-js" @@ -49,6 +49,112 @@ test("opens the key connection prompt from the initially focused add account row } }) +test("starts a new OAuth registration from add account without targeting an existing credential", async () => { + const fixture = await renderIntegration(undefined, undefined, { oauth: true }) + + try { + fixture.app.mockInput.pressEnter() + await fixture.app.waitForFrame( + (frame) => + frame.includes("Browser login") && + frame.includes("API key") && + fixture.app.renderer.currentFocusedEditor instanceof InputRenderable, + ) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.requests.length === 2) + + expect(fixture.requests).toEqual([ + { method: "POST", path: "/api/integration/openai/connect/oauth", body: { methodID: "browser" } }, + { method: "GET", path: "/api/integration/openai/connect/oauth/attempt_test" }, + ]) + expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_personal", "cred_work"]) + fixture.failOAuth() + await fixture.app.waitForFrame((frame) => frame.trim() === "") + expect(fixture.requests).toHaveLength(2) + } finally { + fixture.app.renderer.destroy() + } +}) + +// This HTTP fixture verifies UI request wiring, not core reauthentication or method-switch semantics. +test.each([ + { credentialID: "cred_personal", moves: 1, methodID: "browser" }, + { credentialID: "cred_work", moves: 2, methodID: "browser" }, + { credentialID: "cred_work", moves: 2, methodID: "device" }, +])("starts $methodID OAuth for $credentialID without activating it", async ({ credentialID, moves, methodID }) => { + const fixture = await renderIntegration(undefined, undefined, { oauth: true }) + + try { + expect(fixture.app.captureCharFrame()).toContain("Sign in required") + for (let index = 0; index < moves; index++) fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressEnter() + await fixture.app.waitForFrame( + (frame) => + frame.includes("Browser login") && + frame.includes("Device login") && + frame.includes("API key") && + fixture.app.renderer.currentFocusedEditor instanceof InputRenderable, + ) + expect(fixture.requests).toEqual([]) + if (methodID === "device") fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.requests.length === 2) + + expect(fixture.requests).toEqual([ + { method: "POST", path: "/api/integration/openai/connect/oauth", body: { methodID, credentialID } }, + { method: "GET", path: "/api/integration/openai/connect/oauth/attempt_test" }, + ]) + expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_personal", "cred_work"]) + expect(fixture.reads.model).toBe(0) + expect(fixture.reads.provider).toBe(0) + + fixture.failOAuth() + await fixture.app.waitForFrame((frame) => frame.trim() === "") + expect(fixture.requests).toHaveLength(2) + expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_personal", "cred_work"]) + expect(fixture.reads.model).toBe(0) + expect(fixture.reads.provider).toBe(0) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("starts OAuth from a key credential requiring sign-in without targeting or activating it", async () => { + const fixture = await renderIntegration(undefined, undefined, { oauth: true, credentialMethod: "key" }) + + try { + expect(fixture.accounts[1]?.method).toBe("key") + expect(fixture.accounts[1]?.status?.status).toBe("needs_auth") + fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressEnter() + await fixture.app.waitForFrame( + (frame) => + frame.includes("Browser login") && + frame.includes("API key") && + fixture.app.renderer.currentFocusedEditor instanceof InputRenderable, + ) + expect(fixture.requests).toEqual([]) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.requests.length === 2) + + expect(fixture.requests).toEqual([ + { method: "POST", path: "/api/integration/openai/connect/oauth", body: { methodID: "browser" } }, + { method: "GET", path: "/api/integration/openai/connect/oauth/attempt_test" }, + ]) + expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_personal", "cred_work"]) + + fixture.failOAuth() + await fixture.app.waitForFrame((frame) => frame.trim() === "") + expect(fixture.requests).toHaveLength(2) + expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_personal", "cred_work"]) + expect(fixture.reads.model).toBe(0) + expect(fixture.reads.provider).toBe(0) + } finally { + fixture.app.renderer.destroy() + } +}) + test("skips hidden authentication fields and sends their defaults", async () => { const fixture = await renderIntegration(undefined, [ { type: "string", key: "server", title: "Console URL", hidden: true, default: "https://example.com/console" }, @@ -247,16 +353,29 @@ test("uses the active location for integration data without scoping credential r } }) -async function renderIntegration(activeLocation?: LocationRef, form?: FormFields) { +async function renderIntegration( + activeLocation?: LocationRef, + form?: FormFields, + options?: { oauth?: boolean; credentialMethod?: ConnectionCredentialInfo["method"] }, +) { const events = createEventStream() const requests: Array<{ method: string; path: string; body?: unknown }> = [] const locations: LocationRef[] = [] const credentialQueries: string[] = [] const reads = { integration: 0, model: 0, provider: 0 } - let accounts = [ + const oauthFailure = Promise.withResolvers() + let accounts: ConnectionCredentialInfo[] = [ { type: "credential" as const, method: "key" as const, id: "cred_personal", label: "Personal" }, { type: "credential" as const, method: "key" as const, id: "cred_work", label: "Work" }, - ] + ].map((account) => + options?.oauth + ? { + ...account, + method: options.credentialMethod ?? "oauth", + status: { status: "needs_auth", message: "Sign in required" }, + } + : account, + ) const calls = createFetch(async (url, request) => { const directory = @@ -277,7 +396,15 @@ async function renderIntegration(activeLocation?: LocationRef, form?: FormFields { id: "openai", name: "OpenAI", - methods: [{ type: "key", label: "API key", form }], + methods: [ + ...(options?.oauth + ? [ + { type: "oauth", id: "browser", label: "Browser login" }, + { type: "oauth", id: "device", label: "Device login" }, + ] + : []), + { type: "key", label: "API key", form }, + ], connections: [...accounts, { type: "env", name: "OPENAI_API_KEY" }], }, ], @@ -289,6 +416,30 @@ async function renderIntegration(activeLocation?: LocationRef, form?: FormFields return new Response(null, { status: 204 }) } + if (request.method === "POST" && url.pathname === "/api/integration/openai/connect/oauth") { + requests.push({ method: request.method, path: url.pathname, body: await request.json() }) + return json({ + location, + data: { + attemptID: "attempt_test", + mode: "auto", + url: "https://example.com/oauth", + instructions: "Authorize in your browser", + }, + }) + } + + if (request.method === "GET" && url.pathname === "/api/integration/openai/connect/oauth/attempt_test") { + requests.push({ method: request.method, path: url.pathname }) + await oauthFailure.promise + return json({ location, data: { status: "failed", message: "Authorization failed" } }) + } + + if (request.method === "DELETE" && url.pathname === "/api/integration/openai/connect/oauth/attempt_test") { + requests.push({ method: request.method, path: url.pathname }) + return new Response(null, { status: 204 }) + } + if (url.pathname === "/api/model") { reads.model++ return json({ location, data: [] }) @@ -393,6 +544,7 @@ async function renderIntegration(activeLocation?: LocationRef, form?: FormFields requests, locations, credentialQueries, + failOAuth: () => oauthFailure.resolve(), get accounts() { return accounts },