Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/opencode/src/provider/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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
}
}
51 changes: 51 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
Expand Down
14 changes: 14 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ export type Plugin = (input: PluginInput) => Promise<Hooks>
export type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
/**
* 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<Auth>) => Promise<
| {
type: "success"
refresh: string
access: string
expires: number
accountId?: string
}
| undefined
>
methods: (
| {
type: "oauth"
Expand Down