/** * General-purpose OAuth2 Authorization Code flow with PKCE. * * Supports two callback transports: * * 1. **Loopback** — starts a temporary HTTP server on localhost to receive the * callback directly. Works without any public URL or tunnel. Used by default * when no public ingress URL is configured, and preferred for providers like * Google that support localhost redirects. * * 2. **Gateway** — routes callbacks through the gateway's public OAuth route * + in-memory registry. Requires `ingress.publicBaseUrl` to be configured. * Used for providers that don't support localhost redirects (e.g. Slack). * * Moved from integrations/oauth2.ts. Types that were in integrations/types.ts * are now inlined here since the integration framework is removed. */ import { createHash, randomBytes } from "node:crypto"; import { createServer, type Server } from "node:http"; import { getIsPlatform } from "../config/env-registry.js"; import { getLogger } from "../util/logger.js"; import { renderOAuthCompletionPage as renderLoopbackPage } from "./oauth-completion-page.js"; const log = getLogger("oauth2"); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type TokenEndpointAuthMethod = | "client_secret_basic" | "client_secret_post"; export interface OAuth2Config { authorizeUrl: string; tokenExchangeUrl: string; scopes: string[]; clientId: string; /** Client secret for providers that require it (e.g. Slack). PKCE is always used regardless. */ clientSecret?: string; authorizeParams?: Record; /** URL to fetch user identity info after OAuth. If omitted, account info is not fetched. */ userinfoUrl?: string; /** * How the client authenticates at the token endpoint when a clientSecret is present. * - `client_secret_post`: Send client_id and client_secret in the POST body (default). * - `client_secret_basic`: Send an HTTP Basic Auth header with base64(client_id:client_secret). * Defaults to `client_secret_post`. */ tokenEndpointAuthMethod?: TokenEndpointAuthMethod; /** * Separator used to join scopes in the authorize URL and split the * granted-scope string returned by the token endpoint. Defaults to * `" "` (space) per the OAuth 2.0 spec, but providers like Linear * use `","` (comma). */ scopeSeparator: string; /** * Body encoding format for the token exchange and refresh requests. * - `"form"` (default): `application/x-www-form-urlencoded` with `URLSearchParams`. * - `"json"`: `application/json` with `JSON.stringify`. * Providers like Notion require JSON-encoded bodies at their token endpoint. */ tokenExchangeBodyFormat?: "form" | "json"; /** * When true, include the OAuth `state` in the token-exchange request body. * Non-standard — `state` is normally only validated on the client — but * required by Anthropic's Claude OAuth token endpoint, which hands the caller * a `code#state` pair precisely so both can be posted back at exchange. Off by * default so providers that reject unknown params are unaffected. */ sendStateInTokenExchange?: boolean; } export interface OAuth2TokenResult { accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string; tokenType?: string; } export interface OAuth2FlowCallbacks { /** Open a URL in the user's browser (e.g. via `open_url` message). */ openUrl: (url: string) => void | Promise; } export interface OAuth2FlowOptions { /** Which callback transport to use. When omitted, auto-detected from config. */ callbackTransport?: "loopback" | "gateway"; /** Fixed port for the loopback server. When set, the server binds to this port * instead of an OS-assigned random port. Required for providers like Slack that * need pre-registered redirect URIs. */ loopbackPort?: number; /** Override the loopback callback path. Defaults to `/oauth/callback`. * Required for providers with pre-registered redirect URIs that use a * different path (e.g. OpenAI Codex uses `/auth/callback`). */ loopbackCallbackPath?: string; } export interface OAuth2FlowResult { tokens: OAuth2TokenResult; grantedScopes: string[]; rawTokenResponse: Record; } // --------------------------------------------------------------------------- // PKCE helpers // --------------------------------------------------------------------------- export function generateCodeVerifier(): string { return randomBytes(32).toString("base64url"); } export function generateCodeChallenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } export function generateState(): string { return randomBytes(16).toString("hex"); } // --------------------------------------------------------------------------- // Token exchange (shared between transports) // --------------------------------------------------------------------------- export async function exchangeCodeForTokens( config: OAuth2Config, code: string, redirectUri: string, codeVerifier: string, state?: string, ): Promise { const authMethod = config.tokenEndpointAuthMethod ?? "client_secret_post"; const bodyFormat = config.tokenExchangeBodyFormat ?? "form"; const tokenBody: Record = { grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: codeVerifier, }; // Anthropic's Claude OAuth token endpoint validates the `state` echoed back in // the exchange body; opt-in per provider so others are unaffected. if (config.sendStateInTokenExchange && state) { tokenBody.state = state; } const headers: Record = { "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", Accept: "application/json", }; if (config.clientSecret && authMethod === "client_secret_basic") { const credentials = Buffer.from( `${config.clientId}:${config.clientSecret}`, ).toString("base64"); headers["Authorization"] = `Basic ${credentials}`; } else { tokenBody.client_id = config.clientId; if (config.clientSecret) { tokenBody.client_secret = config.clientSecret; } } const tokenResp = await fetch(config.tokenExchangeUrl, { method: "POST", headers, body: bodyFormat === "json" ? JSON.stringify(tokenBody) : new URLSearchParams(tokenBody), }); if (!tokenResp.ok) { const rawBody = await tokenResp.text().catch(() => ""); const safeDetail: Record = {}; let errorCode = ""; try { const parsed = JSON.parse(rawBody) as Record; const err = parsed.error; if (err && typeof err === "object") { // Anthropic-style nested error: `{ error: { type, message } }`. const nested = err as Record; if (nested.message !== undefined) { safeDetail.error = String(nested.message); } if (nested.type !== undefined) { safeDetail.error_type = String(nested.type); } errorCode = String(nested.type ?? nested.message ?? ""); } else if (err !== undefined) { safeDetail.error = String(err); errorCode = String(err); } if (parsed.error_description !== undefined) { safeDetail.error_description = String(parsed.error_description); } } catch { // Non-JSON error body — keep a bounded slice so failures stay diagnosable. safeDetail.error = rawBody.slice(0, 300) || "[empty response body]"; } log.error( { status: tokenResp.status, ...safeDetail }, "OAuth2 token exchange failed", ); const detail = errorCode ? `HTTP ${tokenResp.status}: ${errorCode}` : `HTTP ${tokenResp.status}`; throw new Error(`OAuth2 token exchange failed (${detail})`); } const tokenData = (await tokenResp.json()) as Record; // Slack V2 OAuth returns user tokens nested under `authed_user` const authedUser = tokenData.authed_user as | Record | undefined; const tokenSource = authedUser?.access_token ? authedUser : tokenData; const tokens: OAuth2TokenResult = { accessToken: (tokenSource.access_token as string) ?? (tokenData.access_token as string), refreshToken: (tokenSource.refresh_token as string | undefined) ?? (tokenData.refresh_token as string | undefined), expiresIn: (tokenSource.expires_in as number | undefined) ?? (tokenData.expires_in as number | undefined), scope: (tokenSource.scope as string | undefined) ?? (tokenData.scope as string | undefined), tokenType: (tokenSource.token_type as string | undefined) ?? (tokenData.token_type as string | undefined), }; // Defensive split: providers (e.g. GitHub, Slack) may return comma-separated // scopes in token responses regardless of the scope_separator used to join // outbound authorize URLs, so we tolerate both spaces and commas here. When // a provider explicitly configures a non-default separator (e.g. Linear uses // ","), we honor that to keep symmetric round-tripping of configured scopes. const splitPattern = config.scopeSeparator === " " ? /[ ,]/ : config.scopeSeparator; const grantedScopes = typeof tokens.scope === "string" ? tokens.scope .split(splitPattern) .map((s) => s.trim()) .filter(Boolean) : [...config.scopes]; return { tokens, grantedScopes, rawTokenResponse: tokenData }; } // --------------------------------------------------------------------------- // Gateway transport // --------------------------------------------------------------------------- async function runGatewayFlow( config: OAuth2Config, callbacks: OAuth2FlowCallbacks, codeVerifier: string, codeChallenge: string, state: string, ): Promise { // Dynamic imports required here to avoid circular dependencies with // config/loader → security → oauth2 module chains. const { resolveOauthCallbackUrl } = await import("../inbound/oauth-callback-url.js"); const { registerPendingCallback } = await import("./oauth-callback-registry.js"); const redirectUri = await resolveOauthCallbackUrl(); const codePromise = new Promise((resolve, reject) => { registerPendingCallback(state, resolve, reject); }); const authParams = new URLSearchParams({ ...config.authorizeParams, client_id: config.clientId, redirect_uri: redirectUri, response_type: "code", scope: config.scopes.join(config.scopeSeparator), state, code_challenge: codeChallenge, code_challenge_method: "S256", }); const authorizeUrl = `${config.authorizeUrl}?${authParams}`; callbacks.openUrl(authorizeUrl); const code = await codePromise; return await exchangeCodeForTokens( config, code, redirectUri, codeVerifier, state, ); } // --------------------------------------------------------------------------- // Loopback transport // --------------------------------------------------------------------------- const LOOPBACK_CALLBACK_PATH = "/oauth/callback"; const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes async function runLoopbackFlow( config: OAuth2Config, callbacks: OAuth2FlowCallbacks, codeVerifier: string, codeChallenge: string, state: string, loopbackPort?: number, callbackPath?: string, ): Promise { const { code, redirectUri } = await startLoopbackServerAndWaitForCode( config, callbacks, codeChallenge, state, loopbackPort, callbackPath, ); return await exchangeCodeForTokens( config, code, redirectUri, codeVerifier, state, ); } /** * Start a temporary HTTP server on localhost, build the auth URL with * a localhost redirect_uri, open the browser, and wait for the callback. * When `loopbackPort` is set, binds to that fixed port (for providers * that require pre-registered redirect URIs); otherwise uses a random port. */ function startLoopbackServerAndWaitForCode( config: OAuth2Config, callbacks: OAuth2FlowCallbacks, codeChallenge: string, state: string, loopbackPort?: number, callbackPath?: string, ): Promise<{ code: string; redirectUri: string }> { const effectiveCallbackPath = callbackPath ?? LOOPBACK_CALLBACK_PATH; return new Promise((resolve, reject) => { let settled = false; let boundRedirectUri = ""; const server: Server = createServer((req, res) => { log.info( { method: req.method, path: new URL(req.url ?? "/", "http://127.0.0.1").pathname, settled, }, "oauth2 loopback: received request", ); if (settled) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(renderLoopbackPage("Authorization already completed", false)); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1`); if (url.pathname !== effectiveCallbackPath) { log.info( { pathname: url.pathname }, "oauth2 loopback: non-callback path, returning 404", ); res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); return; } const callbackState = url.searchParams.get("state"); const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); if (callbackState !== state) { log.warn("oauth2 loopback: state mismatch in callback"); res.writeHead(400, { "Content-Type": "text/html" }); res.end(renderLoopbackPage("Invalid state parameter", false)); return; } settled = true; if (error) { const errorDesc = url.searchParams.get("error_description") ?? error; log.error( { error, errorDesc }, "oauth2 loopback: authorization denied by user/provider", ); res.writeHead(200, { "Content-Type": "text/html" }); res.end( renderLoopbackPage(`Authorization failed: ${errorDesc}`, false), ); cleanup(); reject(new Error(`OAuth2 authorization denied: ${error}`)); return; } if (!code) { log.error("oauth2 loopback: callback missing authorization code"); res.writeHead(400, { "Content-Type": "text/html" }); res.end(renderLoopbackPage("Missing authorization code", false)); cleanup(); reject(new Error("OAuth2 callback missing authorization code")); return; } log.info( "oauth2 loopback: authorization code received, exchanging for tokens", ); res.writeHead(200, { "Content-Type": "text/html" }); res.end( renderLoopbackPage( "You can close this tab and return to your assistant.", true, ), ); cleanup(); resolve({ code, redirectUri: boundRedirectUri }); }); const timeout = setTimeout(() => { if (!settled) { log.warn( { timeoutMs: LOOPBACK_TIMEOUT_MS, state }, "oauth2 loopback: callback timed out — no authorization code received", ); settled = true; cleanup(); reject(new Error("OAuth2 loopback callback timed out")); } }, LOOPBACK_TIMEOUT_MS); if (typeof timeout === "object" && "unref" in timeout) { timeout.unref(); } function cleanup() { clearTimeout(timeout); server.close(); } log.info( { requestedPort: loopbackPort ?? "random" }, "oauth2 loopback: binding server", ); server.listen(loopbackPort ?? 0, "localhost", () => { const addr = server.address() as { port: number }; boundRedirectUri = `http://localhost:${addr.port}${effectiveCallbackPath}`; log.info( { port: addr.port, redirectUri: boundRedirectUri }, "oauth2 loopback: server listening", ); const authParams = new URLSearchParams({ ...config.authorizeParams, client_id: config.clientId, redirect_uri: boundRedirectUri, response_type: "code", scope: config.scopes.join(config.scopeSeparator), state, code_challenge: codeChallenge, code_challenge_method: "S256", }); const authorizeUrl = `${config.authorizeUrl}?${authParams}`; log.info( { authorizeUrlLength: authorizeUrl.length, state }, "oauth2 loopback: built auth URL, calling openUrl callback", ); callbacks.openUrl(authorizeUrl); log.info("oauth2 loopback: openUrl callback returned"); }); server.on("error", (err) => { log.error( { err: err.message, loopbackPort }, "oauth2 loopback: server error", ); if (!settled) { settled = true; cleanup(); reject(new Error(`OAuth2 loopback server error: ${err.message}`)); } }); }); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface OAuth2PreparedFlow { authorizeUrl: string; state: string; /** Resolves when the user completes authorization and tokens are exchanged. */ completion: Promise; } /** * Build an OAuth2 auth URL and register a pending callback, without opening * the URL or blocking. Used by channel sessions where the LLM sends the auth * URL directly in chat and the callback arrives asynchronously via the gateway. * * Supports two transports: * - **loopback** (default): starts a temporary localhost server to receive the * callback. Works without any public URL or tunnel. * - **gateway**: routes callbacks through the public ingress URL. * Requires `ingress.publicBaseUrl` to be configured. Used for providers that * don't support localhost redirects (e.g. Twitter, Notion). */ export async function prepareOAuth2Flow( config: OAuth2Config, options?: OAuth2FlowOptions, ): Promise { const transport = options?.callbackTransport ?? "loopback"; if (transport === "loopback") { return prepareLoopbackFlow( config, options?.loopbackPort, options?.loopbackCallbackPath, ); } // Dynamic imports required here to avoid circular dependencies with // config/loader → security → oauth2 module chains. const { resolveOauthCallbackUrl } = await import("../inbound/oauth-callback-url.js"); const { registerPendingCallback } = await import("./oauth-callback-registry.js"); const redirectUri = await resolveOauthCallbackUrl(); const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); const codePromise = new Promise((resolve, reject) => { registerPendingCallback(state, resolve, reject); }); const authParams = new URLSearchParams({ ...config.authorizeParams, client_id: config.clientId, redirect_uri: redirectUri, response_type: "code", scope: config.scopes.join(config.scopeSeparator), state, code_challenge: codeChallenge, code_challenge_method: "S256", }); const authorizeUrl = `${config.authorizeUrl}?${authParams}`; const completion = codePromise.then(async (code) => { return await exchangeCodeForTokens( config, code, redirectUri, codeVerifier, state, ); }); log.debug({ transport: "gateway", state }, "Prepared deferred OAuth2 flow"); return { authorizeUrl, state, completion }; } /** * Prepare an OAuth2 flow using a loopback server. The server starts immediately * and waits for the callback. The auth URL uses a localhost redirect URI * matching the server's bound port. */ async function prepareLoopbackFlow( config: OAuth2Config, loopbackPort?: number, callbackPath?: string, ): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); const { redirectUri, codePromise } = await startLoopbackServerForPreparedFlow( state, loopbackPort, callbackPath, ); const authParams = new URLSearchParams({ ...config.authorizeParams, client_id: config.clientId, redirect_uri: redirectUri, response_type: "code", scope: config.scopes.join(config.scopeSeparator), state, code_challenge: codeChallenge, code_challenge_method: "S256", }); const authorizeUrl = `${config.authorizeUrl}?${authParams}`; const completion = codePromise.then(async (code) => { return await exchangeCodeForTokens( config, code, redirectUri, codeVerifier, state, ); }); log.debug( { transport: "loopback", loopbackPort, state }, "Prepared deferred OAuth2 flow (loopback)", ); return { authorizeUrl, state, completion }; } /** * Start a loopback server and return the redirect URI and a promise that * resolves with the authorization code. Unlike startLoopbackServerAndWaitForCode, * this does not open the browser — the caller is responsible for delivering * the auth URL to the user. */ function startLoopbackServerForPreparedFlow( state: string, loopbackPort?: number, callbackPath?: string, ): Promise<{ redirectUri: string; codePromise: Promise }> { const effectiveCallbackPath = callbackPath ?? LOOPBACK_CALLBACK_PATH; return new Promise((resolveSetup, rejectSetup) => { let settled = false; let listening = false; let codeResolve: (code: string) => void; let codeReject: (err: Error) => void; const codePromise = new Promise((resolve, reject) => { codeResolve = resolve; codeReject = reject; }); const server: Server = createServer((req, res) => { if (settled) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(renderLoopbackPage("Authorization already completed", false)); return; } const url = new URL(req.url ?? "/", `http://127.0.0.1`); if (url.pathname !== effectiveCallbackPath) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); return; } const callbackState = url.searchParams.get("state"); const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); if (callbackState !== state) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(renderLoopbackPage("Invalid state parameter", false)); return; } settled = true; if (error) { const errorDesc = url.searchParams.get("error_description") ?? error; res.writeHead(200, { "Content-Type": "text/html" }); res.end( renderLoopbackPage(`Authorization failed: ${errorDesc}`, false), ); cleanup(); codeReject(new Error(`OAuth2 authorization denied: ${error}`)); return; } if (!code) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(renderLoopbackPage("Missing authorization code", false)); cleanup(); codeReject(new Error("OAuth2 callback missing authorization code")); return; } res.writeHead(200, { "Content-Type": "text/html" }); res.end( renderLoopbackPage( "You can close this tab and return to your assistant.", true, ), ); cleanup(); codeResolve(code); }); const timeout = setTimeout(() => { if (!settled) { settled = true; cleanup(); codeReject(new Error("OAuth2 loopback callback timed out")); } }, LOOPBACK_TIMEOUT_MS); if (typeof timeout === "object" && "unref" in timeout) { timeout.unref(); } function cleanup() { clearTimeout(timeout); server.close(); } server.listen(loopbackPort ?? 0, "localhost", () => { const addr = server.address() as { port: number }; const redirectUri = `http://localhost:${addr.port}${effectiveCallbackPath}`; listening = true; resolveSetup({ redirectUri, codePromise }); }); server.on("error", (err) => { const message = `OAuth2 loopback server error: ${err.message}`; if (!listening) { // Pre-startup error (e.g. port in use): resolveSetup was never called, // so codePromise has no consumer — only reject the setup promise. settled = true; cleanup(); rejectSetup(new Error(message)); } else if (!settled) { // Post-startup error: setup promise already resolved, reject codePromise // so the caller waiting on it receives the error. settled = true; cleanup(); codeReject(new Error(message)); } }); }); } /** * Run a full OAuth2 authorization code flow with PKCE support. * * Transport selection: * - If `callbackTransport` is explicitly set, that transport is used. * - Otherwise, uses gateway transport when a public ingress URL is configured, * and falls back to loopback (localhost) when it is not. */ export async function startOAuth2Flow( config: OAuth2Config, callbacks: OAuth2FlowCallbacks, options?: OAuth2FlowOptions, ): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); let hasPublicUrl = false; try { // Dynamic imports required here to avoid circular dependencies with // config/loader → security → oauth2 module chains. const { loadConfig } = await import("../config/loader.js"); const { getPublicBaseUrl } = await import("../inbound/public-ingress-urls.js"); getPublicBaseUrl(loadConfig()); hasPublicUrl = true; } catch { // No public URL configured } // When containerized with a platform, callback routes are registered // through the platform gateway — treat as having a public URL. if (!hasPublicUrl && getIsPlatform()) { hasPublicUrl = true; } // Determine transport: explicit option > auto-detect from config const transport = options?.callbackTransport ?? (hasPublicUrl ? "gateway" : "loopback"); log.info( { transport, hasPublicUrl, explicitTransport: options?.callbackTransport, loopbackPort: options?.loopbackPort, }, "startOAuth2Flow: resolved transport", ); if (transport === "gateway") { if (!hasPublicUrl) { throw new Error( "Gateway transport requires a public ingress URL. Set ingress.publicBaseUrl, or use loopback transport.", ); } log.debug({ transport: "gateway" }, "OAuth2 flow starting"); return runGatewayFlow( config, callbacks, codeVerifier, codeChallenge, state, ); } log.debug( { transport: "loopback", loopbackPort: options?.loopbackPort }, "OAuth2 flow starting", ); return runLoopbackFlow( config, callbacks, codeVerifier, codeChallenge, state, options?.loopbackPort, options?.loopbackCallbackPath, ); } // Retry constants for transient failures during token refresh. const REFRESH_MAX_RETRIES = 3; const REFRESH_INITIAL_DELAY_MS = 500; const REFRESH_MAX_DELAY_MS = 4_000; function isRetryableRefreshError(status: number): boolean { return status >= 500 || status === 429; } /** * Refresh an OAuth2 access token using a refresh token. * Supports both PKCE (no secret) and client_secret flows. * * Retries up to {@link REFRESH_MAX_RETRIES} times on transient failures * (network errors, 5xx, 429) with exponential backoff + jitter. Credential * errors (400 invalid_grant/invalid_client, 401, 403) fail immediately. */ export async function refreshOAuth2Token( tokenExchangeUrl: string, clientId: string, refreshToken: string, clientSecret?: string, tokenEndpointAuthMethod?: TokenEndpointAuthMethod, tokenExchangeBodyFormat?: "form" | "json", ): Promise { const authMethod = tokenEndpointAuthMethod ?? "client_secret_post"; const bodyFormat = tokenExchangeBodyFormat ?? "form"; const body: Record = { grant_type: "refresh_token", refresh_token: refreshToken, }; const headers: Record = { "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", Accept: "application/json", }; if (clientSecret && authMethod === "client_secret_basic") { const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( "base64", ); headers["Authorization"] = `Basic ${credentials}`; } else { body.client_id = clientId; if (clientSecret) { body.client_secret = clientSecret; } } const requestBody = bodyFormat === "json" ? JSON.stringify(body) : new URLSearchParams(body); let lastError: Error | undefined; for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) { if (attempt > 0) { const baseDelay = Math.min( REFRESH_INITIAL_DELAY_MS * 2 ** (attempt - 1), REFRESH_MAX_DELAY_MS, ); const jitter = Math.random() * baseDelay * 0.5; const delay = baseDelay + jitter; log.info( { attempt, delayMs: Math.round(delay) }, "Retrying OAuth2 token refresh after transient failure", ); await new Promise((r) => setTimeout(r, delay)); } let resp: Response; try { resp = await fetch(tokenExchangeUrl, { method: "POST", headers, body: requestBody, }); } catch (err) { // Network error (DNS, connection refused, timeout) lastError = err instanceof Error ? err : new Error(`Network error: ${String(err)}`); log.warn( { err: lastError, attempt }, "OAuth2 token refresh network error", ); continue; } if (!resp.ok) { const rawBody = await resp.text().catch(() => ""); const safeDetail: Record = {}; let errorCode = ""; try { const parsed = JSON.parse(rawBody) as Record; if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); } if (parsed.error_description) { safeDetail.error_description = String(parsed.error_description); } } catch { safeDetail.error = "[non-JSON response]"; } const detail = errorCode ? `HTTP ${resp.status}: ${errorCode}` : `HTTP ${resp.status}`; // Credential errors fail immediately — no retry will help. if (!isRetryableRefreshError(resp.status)) { log.error( { status: resp.status, ...safeDetail }, "OAuth2 token refresh failed", ); throw new Error(`OAuth2 token refresh failed (${detail})`); } lastError = new Error(`OAuth2 token refresh failed (${detail})`); log.warn( { status: resp.status, attempt, ...safeDetail }, "OAuth2 token refresh transient failure", ); continue; } const data = (await resp.json()) as Record; return { accessToken: data.access_token as string, refreshToken: (data.refresh_token as string | undefined) ?? refreshToken, expiresIn: data.expires_in as number | undefined, scope: data.scope as string | undefined, tokenType: data.token_type as string | undefined, }; } log.error( { attempts: REFRESH_MAX_RETRIES + 1 }, "OAuth2 token refresh failed after all retries", ); throw lastError ?? new Error("OAuth2 token refresh failed after retries"); }