From 6bdb469008b32f34a5e8f7390c59a1dd5338c803 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Thu, 22 Jan 2026 13:34:59 +0800 Subject: [PATCH] fix(provider): add OAuth token refresh support for GitLab provider Add automatic token refresh when GitLab OAuth tokens expire during a session. Previously, expired tokens would cause 401 errors without recovery. Changes: - Add `refresh` function to AuthHook interface in plugin types - Add `ProviderAuth.refresh()` to invoke plugin refresh logic - Add custom fetch in gitlab loader to handle token refresh: - Proactively refreshes expired tokens before requests - Retries on 401 "invalid_token" errors with refreshed token This requires the external GitLab auth plugin to implement the new `refresh` hook to provide the actual token refresh logic. Fixes #9711 Co-Authored-By: Claude --- packages/opencode/src/provider/auth.ts | 28 ++++++++++++ packages/opencode/src/provider/provider.ts | 51 ++++++++++++++++++++++ packages/plugin/src/index.ts | 14 ++++++ 3 files changed, 93 insertions(+) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e6681ff08914..b18fd0ff98bb 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -144,4 +144,32 @@ export namespace ProviderAuth { ) export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) + + /** + * Refresh an OAuth token for the given provider. + * Returns true if refresh was successful, false otherwise. + */ + export async function refresh(providerID: string): Promise { + const auth = await state().then((s) => s.methods[providerID]) + if (!auth?.refresh) return false + + const result = await auth.refresh(() => Auth.get(providerID) as any) + if (!result) return false + + if (result.type === "success") { + const info: Auth.Info = { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + } + if (result.accountId) { + info.accountId = result.accountId + } + await Auth.set(providerID, info) + return true + } + + return false + } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdd4ccdfb619..937d2abb8439 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -9,6 +9,7 @@ import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" +import { ProviderAuth } from "./auth" import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -409,6 +410,7 @@ export namespace Provider { const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com" const auth = await Auth.get(input.id) + const isOAuth = auth?.type === "oauth" const apiKey = await (async () => { if (auth?.type === "oauth") return auth.access if (auth?.type === "api") return auth.key @@ -428,6 +430,55 @@ export namespace Provider { duo_agent_platform: true, ...(providerConfig?.options?.featureFlags || {}), }, + // Custom fetch to handle OAuth token refresh on 401 + ...(isOAuth && { + fetch: async (requestInput: RequestInfo | URL, init?: RequestInit) => { + // Get fresh auth for each request + const currentAuth = await Auth.get(input.id) + if (currentAuth?.type !== "oauth") { + return fetch(requestInput, init) + } + + // Check if token is expired and refresh proactively + const isExpired = currentAuth.expires > 0 && currentAuth.expires < Date.now() + if (isExpired) { + log.info("gitlab oauth token expired, attempting refresh", { providerID: input.id }) + const refreshed = await ProviderAuth.refresh(input.id) + if (refreshed) { + const newAuth = await Auth.get(input.id) + if (newAuth?.type === "oauth") { + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${newAuth.access}`) + return fetch(requestInput, { ...init, headers }) + } + } + } + + // Make the request with current token + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${currentAuth.access}`) + const response = await fetch(requestInput, { ...init, headers }) + + // Handle 401 with invalid_token error - try to refresh and retry + if (response.status === 401) { + const body = await response.clone().json().catch(() => ({})) + if (body.error === "invalid_token" || body.error_description?.includes("expired")) { + log.info("gitlab oauth token invalid, attempting refresh", { providerID: input.id }) + const refreshed = await ProviderAuth.refresh(input.id) + if (refreshed) { + const newAuth = await Auth.get(input.id) + if (newAuth?.type === "oauth") { + const retryHeaders = new Headers(init?.headers) + retryHeaders.set("Authorization", `Bearer ${newAuth.access}`) + return fetch(requestInput, { ...init, headers: retryHeaders }) + } + } + } + } + + return response + }, + }), }, async getModel(sdk: ReturnType, modelID: string) { return sdk.agenticChat(modelID, { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index d02c8bfe53e5..2118f7db23e1 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -37,6 +37,20 @@ export type Plugin = (input: PluginInput) => Promise export type AuthHook = { provider: string loader?: (auth: () => Promise, provider: Provider) => Promise> + /** + * Refresh an expired OAuth token. Called when a 401 "invalid_token" error is received. + * Returns the new auth info on success, or undefined if refresh is not supported. + */ + refresh?: (auth: () => Promise) => Promise< + | { + type: "success" + refresh: string + access: string + expires: number + accountId?: string + } + | undefined + > methods: ( | { type: "oauth"