From ed332849ba2df557c3829cfe81d66ed4f7366ab4 Mon Sep 17 00:00:00 2001 From: Artur Do Lago Date: Sat, 10 Jan 2026 17:49:00 +0100 Subject: [PATCH] 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 --- packages/agent-core/src/auth/index.ts | 145 +++++++++++++++++++ packages/agent-core/src/provider/provider.ts | 3 + 2 files changed, 148 insertions(+) diff --git a/packages/agent-core/src/auth/index.ts b/packages/agent-core/src/auth/index.ts index 4e908118e8f..0739517e563 100644 --- a/packages/agent-core/src/auth/index.ts +++ b/packages/agent-core/src/auth/index.ts @@ -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 = { + 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 { + 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 + > { + const allAuth = await all() + const result: Record = {} + + 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 + } } diff --git a/packages/agent-core/src/provider/provider.ts b/packages/agent-core/src/provider/provider.ts index 41a2791eb6e..945ff0e3dc2 100644 --- a/packages/agent-core/src/provider/provider.ts +++ b/packages/agent-core/src/provider/provider.ts @@ -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