From 944d24a212aa02d819286e6a1016ea666bbbe492 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 21 Sep 2026 23:18:54 +0200 Subject: [PATCH] feat(core): add GitLab OAuth (PKCE) login to the built-in provider Registers a PKCE OAuth method on the `gitlab` integration so browser login works without the external opencode-gitlab-auth plugin. Token storage and refresh scheduling are already owned by core; this supplies the authorize, refresh, and label implementations plus a self-managed instance URL field. The bundled client ID is an instance-owned, trusted application registered for the loopback redirect; GITLAB_OAUTH_CLIENT_ID overrides it for self-managed instances. GitLab matches redirect_uri exactly and also requires it on the refresh grant, so the callback port is fixed and EADDRINUSE reports a clear message. Workflow discovery now prefers the instance an OAuth credential was issued against over ambient defaults. --- packages/core/src/plugin/provider/gitlab.ts | 246 +++++++++++++- .../test/plugin/provider-gitlab-oauth.test.ts | 315 ++++++++++++++++++ packages/web/src/content/docs/providers.mdx | 15 +- 3 files changed, 562 insertions(+), 14 deletions(-) create mode 100644 packages/core/test/plugin/provider-gitlab-oauth.test.ts diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index be6d66425bbc..9122f9f821ab 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,24 +1,145 @@ import os from "os" import { App } from "../../app.js" -import { Effect, Semaphore, Stream } from "effect" +import { Clock, Deferred, Effect, Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import type { ServerResponse } from "node:http" import { define } from "@opencode/plugin/effect/plugin" +import { Form } from "@opencode/schema/form" import type { DiscoveredWorkflowModel } from "gitlab-ai-provider" import { Bus } from "../../bus.js" import { Credential } from "../../credential.js" import { Integration } from "../../integration.js" import { IntegrationConnection } from "../../integration/connection.js" import { Model } from "../../model.js" +import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" import type { PluginInternal } from "../internal.js" const providerID = Provider.ID.gitlab const integrationID = Integration.ID.make("gitlab") +const methodID = Integration.MethodID.make("pkce") +// Instance-owned, trusted GitLab OAuth application for OpenCode. Registered with +// redirect URI http://127.0.0.1:8080/callback on gitlab.com. Self-managed instances +// need their own application; override with GITLAB_OAUTH_CLIENT_ID in that case. +const bundledClientID = "fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7" +const oauthScope = "api" +const callbackHost = "127.0.0.1" +const callbackPort = 8080 +const redirectURI = `http://${callbackHost}:${callbackPort}/callback` + +const Token = Schema.Struct({ + access_token: Schema.NonEmptyString, + refresh_token: Schema.NonEmptyString, + expires_in: Schema.optional(Schema.Number), +}) +const decodeError = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }), + ), +) + +function resolveClientID() { + return process.env.GITLAB_OAUTH_CLIENT_ID?.trim() || bundledClientID +} + +function resolveDefaultInstanceUrl() { + return process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com" +} + +function normalizeInstanceUrl(value: string | undefined) { + const raw = value?.trim() || resolveDefaultInstanceUrl() + const url = new URL(raw) + return `${url.protocol}//${url.host}` +} + +function instanceUrlField() { + const placeholder = resolveDefaultInstanceUrl() + return { + type: "string" as const, + key: "instanceUrl", + title: "GitLab instance URL", + description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", + placeholder, + default: placeholder, + pattern: "^https?://\\S+$", + } +} + +function credentialInstanceUrl(metadata: Readonly> | undefined) { + const value = metadata?.instanceUrl + return typeof value === "string" && value ? value : resolveDefaultInstanceUrl() +} + +// GitLab returns one generic `invalid_grant` for a reused/expired code, a PKCE +// verifier mismatch, a redirect-URI mismatch, and a missing client-secret alike. +// Surface enough context to tell those apart without leaking the verifier/code. +function describeGrantFailure(detail: string | undefined, clientID: string) { + if (!detail) return "GitLab token exchange failed" + if (!detail.includes("invalid_grant") && !detail.includes("invalid_client")) return detail + const custom = clientID !== bundledClientID + const hints = [ + "the authorization code was already used, or came from an older login attempt. Codes are" + + " single-use and bound to one PKCE verifier, so a stale browser tab or a reloaded callback" + + " page fails here. Start a completely fresh login.", + `redirect_uri sent: ${redirectURI} — the application must register this exactly`, + `client_id used: ${clientID.slice(0, 12)}...${custom ? " (from GITLAB_OAUTH_CLIENT_ID)" : " (bundled default)"}`, + ] + if (custom) { + hints.push( + "confirm that application registers the redirect URI above, grants the `api` scope, and is" + + ' NOT marked "Confidential" (PKCE requires a public client). Unset GITLAB_OAUTH_CLIENT_ID to' + + " fall back to the bundled application.", + ) + } + return `${detail}\n\nLikely causes:\n- ${hints.join("\n- ")}` +} + +function exchange(http: HttpClient.HttpClient, instanceUrl: string, clientID: string, body: Record) { + return Effect.gen(function* () { + const response = yield* http + .execute( + HttpClientRequest.post(`${instanceUrl}/oauth/token`).pipe( + HttpClientRequest.bodyUrlParams({ ...body, client_id: clientID }), + ), + ) + .pipe(Effect.mapError(() => new Error("GitLab token exchange request failed"))) + if (response.status < 200 || response.status >= 300) { + const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + const parsed = decodeError(text) + const detail = parsed._tag === "Some" ? parsed.value.error_description || parsed.value.error : undefined + return yield* Effect.fail(new Error(describeGrantFailure(detail ?? `HTTP ${response.status}`, clientID))) + } + return yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe( + Effect.mapError(() => new Error("Invalid GitLab token response")), + ) + }) +} + +function credential(instanceUrl: string, tokens: typeof Token.Type) { + return Effect.map(Clock.currentTimeMillis, (now) => + Credential.OAuth.make({ + type: "oauth", + methodID, + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: now + (tokens.expires_in ?? 7200) * 1000, + metadata: { instanceUrl }, + }), + ) +} + +function callbackError(params: URLSearchParams, state: string) { + if (params.get("state") !== state) return "Invalid OAuth state" + if (params.has("error")) return params.get("error_description") || params.get("error") || "Authorization denied" + return params.get("code")?.trim() ? undefined : "Missing authorization code" +} export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { const providers = yield* Provider.Service const bus = yield* Bus.Service + const http = yield* HttpClient.HttpClient const loading = Semaphore.makeUnsafe(1) const loaded: { models?: DiscoveredWorkflowModel[] @@ -42,10 +163,13 @@ export const GitLabPlugin = define({ return } + // An OAuth login pins the instance it was issued against; prefer it over ambient defaults. const instanceUrl = - typeof provider?.settings?.instanceUrl === "string" - ? provider.settings.instanceUrl - : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com") + credential?.type === "oauth" && typeof credential.metadata?.instanceUrl === "string" + ? credential.metadata.instanceUrl + : typeof provider?.settings?.instanceUrl === "string" + ? provider.settings.instanceUrl + : resolveDefaultInstanceUrl() // The SDK owns project detection, GraphQL discovery, caching and token limits. const remote = yield* Effect.tryPromise({ try: async () => { @@ -74,6 +198,120 @@ export const GitLabPlugin = define({ loaded.connection = connection }) + yield* ctx.integration.transform((editor) => { + editor.method.update({ + integrationID, + method: { + id: methodID, + type: "oauth", + label: "Login with GitLab (OAuth)", + form: Form.Fields.make([instanceUrlField()]), + }, + label: (value) => new URL(credentialInstanceUrl(value.metadata)).host, + refresh: (value) => + Effect.gen(function* () { + const instanceUrl = credentialInstanceUrl(value.metadata) + const clientID = resolveClientID() + const tokens = yield* exchange(http, instanceUrl, clientID, { + grant_type: "refresh_token", + refresh_token: value.refresh, + redirect_uri: redirectURI, + }) + return yield* credential(instanceUrl, tokens) + }), + authorize: (answer) => + Effect.gen(function* () { + // Capture once so the authorize request and the token exchange cannot + // disagree if the environment changes mid-flow. + const clientID = resolveClientID() + const instanceUrl = normalizeInstanceUrl( + typeof answer.instanceUrl === "string" ? answer.instanceUrl : undefined, + ) + const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + const challenge = Buffer.from( + yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))), + ).toString("base64url") + const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + + const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>() + const { createServer } = yield* Effect.promise(() => import("node:http")) + const { EventEmitter } = yield* Effect.promise(() => import("node:events")) + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${callbackHost}`) + if (request.method !== "GET" || url.pathname !== "/callback") { + response.writeHead(404).end() + return + } + const error = callbackError(url.searchParams, state) + if (error) { + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "GitLab" })) + Effect.runSync(Deferred.fail(callback, new Error(error))) + return + } + if (!Effect.runSync(Deferred.succeed(callback, { code: url.searchParams.get("code") ?? "", response }))) + response.writeHead(409).end("OAuth callback already received") + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.close() + server.closeAllConnections() + }), + ) + // GitLab matches redirect_uri exactly, so the port cannot be ephemeral. + yield* Effect.tryPromise({ + try: () => EventEmitter.once(server.listen(callbackPort, callbackHost), "listening"), + catch: (cause) => + "code" in (cause as { code?: string }) && (cause as { code?: string }).code === "EADDRINUSE" + ? new Error( + `GitLab login needs local port ${callbackPort}, but it is already in use. Stop the process using that port and try again.`, + ) + : cause, + }) + + return { + mode: "auto" as const, + url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({ + client_id: clientID, + redirect_uri: redirectURI, + response_type: "code", + state, + scope: oauthScope, + code_challenge: challenge, + code_challenge_method: "S256", + }).toString()}`, + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Effect.gen(function* () { + const request = yield* Deferred.await(callback) + const respond = (error?: string) => + Effect.sync(() => + request.response + .writeHead(error ? 400 : 200, { "Content-Type": "text/html" }) + .end( + error + ? OauthCallbackPage.error(error, { provider: "GitLab" }) + : OauthCallbackPage.success({ provider: "GitLab" }), + ), + ) + return yield* exchange(http, instanceUrl, clientID, { + grant_type: "authorization_code", + code: request.code, + redirect_uri: redirectURI, + code_verifier: verifier, + }).pipe( + Effect.flatMap((tokens) => credential(instanceUrl, tokens)), + Effect.tap(() => respond()), + Effect.tapError((error) => respond(error.message)), + // Bun's server.closeAllConnections() leaves an unanswered callback response pending. + Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())), + ) + }), + } + }), + }) + }) + yield* ctx.provider.transform((editor) => { const item = editor.get(providerID) if (!item || !loaded.models?.length) return diff --git a/packages/core/test/plugin/provider-gitlab-oauth.test.ts b/packages/core/test/plugin/provider-gitlab-oauth.test.ts new file mode 100644 index 000000000000..1d2d25224078 --- /dev/null +++ b/packages/core/test/plugin/provider-gitlab-oauth.test.ts @@ -0,0 +1,315 @@ +import { describe, expect } from "bun:test" +import { Clock, Effect, Schedule } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { Credential } from "@opencode/core/credential" +import { Integration } from "@opencode/core/integration" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { GitLabPlugin } from "@opencode/core/plugin/provider/gitlab" +import { ProviderPlugins } from "@opencode/core/plugin/provider" +import { withEnv } from "../fixture/env" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) +const integrationID = Integration.ID.make("gitlab") +const methodID = Integration.MethodID.make("pkce") +const bundledClientID = "fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7" + +const fixture = Effect.fn(function* () { + const requests: Request[] = [] + const replies: (Response | Effect.Effect)[] = [] + const http = HttpClient.make((request) => + Effect.gen(function* () { + requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)) + const response = replies.shift() + if (!response) throw new Error(`Unexpected request: ${request.url}`) + return HttpClientResponse.fromWeb(request, yield* Effect.isEffect(response) ? response : Effect.succeed(response)) + }), + ) + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + yield* integrations.transform((editor) => { + editor.method.update({ integrationID, method: { type: "key" } }) + }) + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* GitLabPlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http)) + const status = (attemptID: Integration.AttemptID) => + integrations.oauth + .status({ integrationID, attemptID }) + .pipe( + Effect.repeat({ until: (value) => value.status !== "pending", schedule: Schedule.spaced("1 millis") }), + Effect.timeout("3 seconds"), + ) + const connect = Effect.gen(function* () { + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "GitLab login" }) + const url = new URL(attempt.url) + const callback = new URL(url.searchParams.get("redirect_uri") ?? "") + callback.searchParams.set("state", url.searchParams.get("state") ?? "") + callback.searchParams.set("code", "auth-code") + return { attempt, url, callback } + }) + return { requests, replies, integrations, credentials, status, connect } +}) + +describe("GitLabPlugin OAuth", () => { + it.effect("is registered alongside the other provider plugins", () => + Effect.gen(function* () { + expect(ProviderPlugins).toContain(GitLabPlugin) + }), + ) + + it.effect("registers PKCE OAuth alongside the generic key/env methods from ModelsDevPlugin", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.method.update({ integrationID, method: { type: "key" } }) + editor.method.update({ integrationID, method: { type: "env", names: ["GITLAB_TOKEN"] } }) + }) + const test = yield* fixture() + expect((yield* integrations.get(integrationID))?.methods).toEqual([ + { type: "key" }, + { type: "env", names: ["GITLAB_TOKEN"] }, + { + id: methodID, + type: "oauth", + label: "Login with GitLab (OAuth)", + form: [ + { + type: "string", + key: "instanceUrl", + title: "GitLab instance URL", + description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", + placeholder: "https://gitlab.com", + default: "https://gitlab.com", + pattern: "^https?://\\S+$", + }, + ], + }, + ]) + expect(test.requests).toHaveLength(0) + }), + ) + + it.live("exchanges a PKCE code for a native GitLab OAuth credential against gitlab.com by default", () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + expect(login.url.origin + login.url.pathname).toBe("https://gitlab.com/oauth/authorize") + expect(Object.fromEntries(login.url.searchParams)).toMatchObject({ + response_type: "code", + client_id: bundledClientID, + scope: "api", + code_challenge_method: "S256", + }) + expect(login.url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback") + expect(login.callback.hostname).toBe("127.0.0.1") + expect(login.callback.port).toBe("8080") + expect(login.callback.pathname).toBe("/callback") + + const now = Date.now() + test.replies.push( + Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 7200 }), + ) + const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(page.status).toBe(200) + expect(yield* Effect.promise(() => page.text())).toContain("Authorization successful") + expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete") + + const exchange = test.requests[0] + expect(exchange.url).toBe("https://gitlab.com/oauth/token") + expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded") + const form = new URLSearchParams(yield* Effect.promise(() => exchange.text())) + expect(Object.fromEntries(form)).toMatchObject({ + grant_type: "authorization_code", + client_id: bundledClientID, + code: "auth-code", + redirect_uri: "http://127.0.0.1:8080/callback", + }) + expect(login.url.searchParams.get("code_challenge")).toBe( + Buffer.from( + yield* Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")), + ), + ).toString("base64url"), + ) + + const saved = (yield* test.credentials.list(integrationID))[0]?.value + if (saved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(saved.access).toBe("access-token") + expect(saved.refresh).toBe("refresh-token") + expect(saved.metadata).toEqual({ instanceUrl: "https://gitlab.com" }) + expect(saved.expires).toBeGreaterThanOrEqual(now + 7_200_000) + expect(saved.expires).toBeLessThanOrEqual(Date.now() + 7_200_000) + }), + ) + + it.live("uses the answered self-managed instance URL for authorize and token exchange", () => + Effect.gen(function* () { + const test = yield* fixture() + const integrations = yield* Integration.Service + const attempt = yield* integrations.oauth.connect({ + integrationID, + methodID, + answer: { instanceUrl: "https://gitlab.example.com/" }, + }) + const url = new URL(attempt.url) + expect(url.origin + url.pathname).toBe("https://gitlab.example.com/oauth/authorize") + const callback = new URL(url.searchParams.get("redirect_uri") ?? "") + callback.searchParams.set("state", url.searchParams.get("state") ?? "") + callback.searchParams.set("code", "auth-code") + test.replies.push( + Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }), + ) + yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } })) + expect((yield* test.status(attempt.attemptID)).status).toBe("complete") + expect(test.requests[0]?.url).toBe("https://gitlab.example.com/oauth/token") + const saved = (yield* test.credentials.list(integrationID))[0]?.value + expect(saved?.metadata).toEqual({ instanceUrl: "https://gitlab.example.com" }) + }), + ) + + it.effect("labels a stored credential with the instance host", () => + Effect.gen(function* () { + yield* fixture() + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const saved = yield* credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 0, + metadata: { instanceUrl: "https://gitlab.example.com" }, + }), + }) + const active = yield* integrations.connection.active(integrationID) + expect(active).toMatchObject({ type: "credential", id: saved.id, label: "default" }) + }), + ) + + it.effect("refreshes an OAuth credential using its stored instance URL and includes redirect_uri", () => + Effect.gen(function* () { + const test = yield* fixture() + const credential = Credential.OAuth.make({ + type: "oauth", + methodID, + access: "stale-access", + refresh: "stored-refresh", + expires: 1, + metadata: { instanceUrl: "https://gitlab.example.com" }, + }) + const saved = yield* test.credentials.create({ integrationID, value: credential }) + // Creating the credential also wakes workflow discovery, which resolves the same + // expired credential and races for a refresh. Answer every refresh identically and + // assert on the token requests rather than on a single queued reply. + const renewal = () => + Response.json({ access_token: "renewed-access", refresh_token: "renewed-refresh", expires_in: 3600 }) + test.replies.push(renewal(), renewal(), renewal()) + const connection = { type: "credential" as const, id: saved.id, label: saved.label } + const now = yield* Clock.currentTimeMillis + const resolved = yield* test.integrations.connection.resolve(connection) + if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(resolved.access).toBe("renewed-access") + expect(resolved.refresh).toBe("renewed-refresh") + expect(resolved.expires).toBeGreaterThanOrEqual(now + 3_600_000) + + const tokenRequest = test.requests.find((request) => request.url.endsWith("/oauth/token")) + expect(tokenRequest?.url).toBe("https://gitlab.example.com/oauth/token") + const refresh = new URLSearchParams(yield* Effect.promise(() => tokenRequest?.text() ?? Promise.resolve(""))) + expect(Object.fromEntries(refresh)).toMatchObject({ + grant_type: "refresh_token", + refresh_token: "stored-refresh", + client_id: bundledClientID, + redirect_uri: "http://127.0.0.1:8080/callback", + }) + }), + ) + + for (const invalid of [ + { params: { state: "" }, message: "Invalid OAuth state" }, + { params: { code: "" }, message: "Missing authorization code" }, + { params: { error: "access_denied", error_description: "User declined access" }, message: "User declined access" }, + { params: { error: "access_denied" }, message: "access_denied" }, + ]) { + it.live(`rejects invalid or denied callbacks (${JSON.stringify(invalid.params)})`, () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + Object.entries(invalid.params).forEach(([key, value]) => login.callback.searchParams.set(key, value)) + const response = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(response.status).toBe(400) + expect(yield* Effect.promise(() => response.text())).toContain("Authorization failed") + expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ + status: "failed", + message: invalid.message, + }) + expect(test.requests).toHaveLength(0) + expect(yield* test.credentials.list(integrationID)).toEqual([]) + }), + ) + } + + for (const response of [ + { + status: 400, + body: JSON.stringify({ error: "invalid_grant", error_description: "Code expired" }), + contains: "Code expired", + }, + { + status: 400, + body: JSON.stringify({ error: "invalid_grant" }), + contains: "client_id used:", + }, + { status: 502, body: "Bad gateway", contains: "HTTP 502" }, + ]) { + it.live(`preserves the active connection after a failed token exchange (${response.status})`, () => + Effect.gen(function* () { + const test = yield* fixture() + yield* test.integrations.connection.key({ integrationID, key: "previous-key" }) + const previous = yield* test.integrations.connection.active(integrationID) + const saved = yield* test.credentials.list(integrationID) + const login = yield* test.connect + test.replies.push(new Response(response.body, { status: response.status })) + const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(page.status).toBe(400) + const text = yield* Effect.promise(() => page.text()) + expect(text).toContain(response.contains) + expect(yield* test.credentials.list(integrationID)).toEqual(saved) + expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous) + }), + ) + } + + it.live("reports EADDRINUSE with a clear message when the callback port is occupied", () => + Effect.gen(function* () { + const { createServer } = yield* Effect.promise(() => import("node:http")) + const { EventEmitter } = yield* Effect.promise(() => import("node:events")) + const blocker = createServer() + yield* Effect.tryPromise(() => EventEmitter.once(blocker.listen(8080, "127.0.0.1"), "listening")) + yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close())) + const test = yield* fixture() + const error = yield* test.integrations.oauth.connect({ integrationID, methodID }).pipe(Effect.flip) + expect(error.message).toContain("port 8080") + expect(error.message).toContain("already in use") + }), + ) + + it.live("honors GITLAB_OAUTH_CLIENT_ID for self-managed applications", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: "custom-client-id" }, () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + expect(login.url.searchParams.get("client_id")).toBe("custom-client-id") + test.replies.push(Response.json({ access_token: "a", refresh_token: "r", expires_in: 3600 })) + yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete") + const form = new URLSearchParams(yield* Effect.promise(() => test.requests[0]?.text() ?? Promise.resolve(""))) + expect(form.get("client_id")).toBe("custom-client-id") + }), + ), + ) +}) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index a5a17de3a34d..fc04b213ec9c 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1009,22 +1009,17 @@ Your GitLab administrator must: ##### OAuth for Self-Hosted instances -In order to make Oauth working for your self-hosted instance, you need to create -a new application (Settings → Applications) with the -callback URL `http://127.0.0.1:8080/callback` and following scopes: +OpenCode's bundled GitLab OAuth application only exists on gitlab.com. To use +OAuth with a self-hosted instance, create your own application (Settings → +Applications) with the callback URL `http://127.0.0.1:8080/callback`, the +`api` scope, and **Confidential** unchecked (PKCE requires a public client). -- api (Access the API on your behalf) -- read_user (Read your personal information) -- read_repository (Allows read-only access to the repository) - -Then expose application ID as environment variable: +Then expose the application ID as an environment variable: ```bash export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ``` -More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) homepage. - ##### Configuration Customize through `opencode.json`: