feat(auth): add proactive OAuth token refresh

- Add Auth.refreshToken() to refresh a specific provider's token
- Add Auth.refreshAllExpiring() to refresh all tokens expiring within 10 min
- Add Auth.status() to get token validity info for all providers
- Add Auth.isExpiringSoon() and Auth.isExpired() helpers
- Call refreshAllExpiring() during provider initialization

Supported providers: anthropic, openai
Note: google/antigravity uses plugin-handled custom flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-10 17:49:00 +01:00
parent 91263c6c78
commit ed332849ba
2 changed files with 148 additions and 0 deletions
+145
View File
@@ -2,9 +2,26 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { Log } from "../util/log"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
// Buffer time before expiry to trigger refresh (10 minutes)
const REFRESH_BUFFER_MS = 10 * 60 * 1000
// OAuth refresh configurations for known providers
// Note: google/antigravity uses a custom flow handled by the plugin, not standard OAuth
const OAUTH_REFRESH_CONFIG: Record<string, { url: string; clientId: string }> = {
anthropic: {
url: "https://console.anthropic.com/v1/oauth/token",
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
},
openai: {
url: "https://auth.openai.com/oauth/token",
clientId: "pdlLIX2Y72MgDktxw22rHpPdJKmlMVBi", // ChatGPT client ID
},
}
export namespace Auth {
export const Oauth = z
.object({
@@ -71,4 +88,132 @@ export namespace Auth {
await Bun.write(file, JSON.stringify(data, null, 2))
await fs.chmod(file.name!, 0o600)
}
const log = Log.create({ service: "auth" })
/**
* Check if an OAuth token is expiring soon (within buffer)
*/
export function isExpiringSoon(auth: Info): boolean {
if (auth.type !== "oauth") return false
return auth.expires < Date.now() + REFRESH_BUFFER_MS
}
/**
* Check if an OAuth token is expired
*/
export function isExpired(auth: Info): boolean {
if (auth.type !== "oauth") return false
return auth.expires < Date.now()
}
/**
* Refresh an OAuth token for a known provider
*/
export async function refreshToken(providerID: string): Promise<boolean> {
const auth = await get(providerID)
if (!auth || auth.type !== "oauth") return false
const config = OAUTH_REFRESH_CONFIG[providerID]
if (!config) {
log.warn("no refresh config for provider", { providerID })
return false
}
try {
log.info("refreshing token", { providerID, expiresIn: Math.round((auth.expires - Date.now()) / 1000) })
const response = await fetch(config.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: auth.refresh,
client_id: config.clientId,
}),
})
if (!response.ok) {
log.error("token refresh failed", { providerID, status: response.status })
return false
}
const json = (await response.json()) as {
access_token: string
refresh_token?: string
expires_in: number
}
await set(providerID, {
...auth,
type: "oauth",
access: json.access_token,
refresh: json.refresh_token ?? auth.refresh,
expires: Date.now() + json.expires_in * 1000,
})
log.info("token refreshed", { providerID, expiresIn: json.expires_in })
return true
} catch (error) {
log.error("token refresh error", { providerID, error: String(error) })
return false
}
}
/**
* Proactively refresh all OAuth tokens that are expiring soon
*/
export async function refreshAllExpiring(): Promise<{ refreshed: string[]; failed: string[] }> {
const allAuth = await all()
const refreshed: string[] = []
const failed: string[] = []
for (const [providerID, auth] of Object.entries(allAuth)) {
if (auth.type !== "oauth") continue
if (!isExpiringSoon(auth)) continue
if (!OAUTH_REFRESH_CONFIG[providerID]) continue
const success = await refreshToken(providerID)
if (success) {
refreshed.push(providerID)
} else {
failed.push(providerID)
}
}
if (refreshed.length > 0 || failed.length > 0) {
log.info("proactive token refresh complete", { refreshed, failed })
}
return { refreshed, failed }
}
/**
* Get status of all OAuth tokens
*/
export async function status(): Promise<
Record<string, { valid: boolean; expiringSoon: boolean; expiresIn: number | null }>
> {
const allAuth = await all()
const result: Record<string, { valid: boolean; expiringSoon: boolean; expiresIn: number | null }> = {}
for (const [providerID, auth] of Object.entries(allAuth)) {
if (auth.type === "oauth") {
const expiresIn = Math.round((auth.expires - Date.now()) / 1000)
result[providerID] = {
valid: !isExpired(auth),
expiringSoon: isExpiringSoon(auth),
expiresIn,
}
} else {
result[providerID] = {
valid: true,
expiringSoon: false,
expiresIn: null,
}
}
}
return result
}
}
@@ -697,6 +697,9 @@ export namespace Provider {
log.info("init")
// Proactively refresh OAuth tokens that are expiring soon
await Auth.refreshAllExpiring()
const configProviders = Object.entries(config.provider ?? {})
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot