diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a6091ac..c2382fb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Reduced Sentry span sampling to 10% outside development. [#1475](https://github.com/sourcebot-dev/sourcebot/pull/1475) +### Fixed +- [EE] Preserved cached repository permissions when OAuth token refresh fails because of a transient code host outage. [#1481](https://github.com/sourcebot-dev/sourcebot/pull/1481) + ## [5.1.3] - 2026-07-20 ### Added diff --git a/packages/backend/src/ee/accountPermissionSyncer.test.ts b/packages/backend/src/ee/accountPermissionSyncer.test.ts new file mode 100644 index 000000000..b2499e6e8 --- /dev/null +++ b/packages/backend/src/ee/accountPermissionSyncer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest'; +import { classifyPermissionSyncFailure } from './accountPermissionSyncer.js'; +import { TokenRefreshError, type TokenRefreshErrorKind } from './tokenRefresh.js'; + +const tokenRefreshError = ( + kind: TokenRefreshErrorKind, + status?: number, +): TokenRefreshError => new TokenRefreshError(`Token refresh failed: ${kind}`, { + kind, + status, +}); + +describe('classifyPermissionSyncFailure', () => { + test('fails closed for invalid_grant', () => { + expect(classifyPermissionSyncFailure(tokenRefreshError('invalid_grant', 400))).toEqual({ + action: 'clear_permissions', + reason: 'oauth_invalid_grant', + }); + }); + + test.each([ + ['transient', 500], + ['configuration', 400], + ['invalid_response', undefined], + ['local_credential', undefined], + ] satisfies Array<[TokenRefreshErrorKind, number | undefined]>)('keeps permissions for a %s token refresh failure', (kind, status) => { + expect(classifyPermissionSyncFailure(tokenRefreshError(kind, status))).toEqual({ + action: 'preserve_permissions', + }); + }); + + test('does not treat a token refresh configuration error with HTTP 401 as an API authorization failure', () => { + expect(classifyPermissionSyncFailure(tokenRefreshError('configuration', 401))).toEqual({ + action: 'preserve_permissions', + }); + }); + + test.each([ + [401, 'http_unauthorized'], + [403, 'http_forbidden'], + [410, 'http_gone'], + ] as const)('preserves fail-closed behavior for an API HTTP %s response', (status, reason) => { + const error = Object.assign(new Error(reason), { status }); + expect(classifyPermissionSyncFailure(error)).toEqual({ + action: 'clear_permissions', + reason, + }); + }); + + test('keeps permissions for an unrelated API failure', () => { + const error = Object.assign(new Error('Internal Server Error'), { status: 500 }); + expect(classifyPermissionSyncFailure(error)).toEqual({ + action: 'preserve_permissions', + }); + }); +}); diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 6394e719c..382d773fe 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/node"; import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; import { env, createLogger, getIdentityProviderConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; import { hasEntitlement } from "../entitlements.js"; -import { ensureFreshAccountToken } from "./tokenRefresh.js"; +import { ensureFreshAccountToken, TokenRefreshError } from "./tokenRefresh.js"; import { DelayedError, Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; import { @@ -32,11 +32,50 @@ type AccountPermissionSyncJob = { jobId: string; } -class RefreshTokenError extends Error { - constructor(message: string) { - super(message); +export type PermissionCleanupReason = + | 'oauth_invalid_grant' + | 'http_unauthorized' + | 'http_forbidden' + | 'http_gone'; + +export type PermissionCleanupDecision = + | { + action: 'clear_permissions'; + reason: PermissionCleanupReason; } -} + | { + action: 'preserve_permissions'; + }; + +const PERMISSION_CLEANUP_REASON_MESSAGES: Record = { + oauth_invalid_grant: 'OAuth invalid_grant', + http_unauthorized: 'HTTP 401 Unauthorized', + http_forbidden: 'HTTP 403 Forbidden', + http_gone: 'HTTP 410 Gone', +}; + +export const classifyPermissionSyncFailure = (error: unknown): PermissionCleanupDecision => { + // Token refresh failures have their own classification. Do not fall through + // to the generic HTTP checks because a non-invalid_grant response may also + // carry a 401 or 403 status. + if (error instanceof TokenRefreshError) { + return error.kind === 'invalid_grant' + ? { action: 'clear_permissions', reason: 'oauth_invalid_grant' } + : { action: 'preserve_permissions' }; + } + + if (isUnauthorized(error)) { + return { action: 'clear_permissions', reason: 'http_unauthorized' }; + } + if (isForbidden(error)) { + return { action: 'clear_permissions', reason: 'http_forbidden' }; + } + if (isGone(error)) { + return { action: 'clear_permissions', reason: 'http_gone' }; + } + + return { action: 'preserve_permissions' }; +}; export class AccountPermissionSyncer { private queue: Queue; @@ -202,18 +241,14 @@ export class AccountPermissionSyncer { // on is gone (e.g. Bitbucket Cloud's CHANGE-2770), clear the // account's existing permission rows so the read-side filter stops // matching through them. - const reason = - error instanceof RefreshTokenError ? 'token refresh failure' : - isUnauthorized(error) ? 'HTTP 401 Unauthorized' : - isForbidden(error) ? 'HTTP 403 Forbidden' : - isGone(error) ? 'HTTP 410 Gone' : - null; - - if (reason !== null) { + const cleanupDecision = classifyPermissionSyncFailure(error); + + if (cleanupDecision.action === 'clear_permissions') { const { count } = await this.db.accountToRepoPermission.deleteMany({ where: { accountId: account.id }, }); const message = error instanceof Error ? error.message : String(error); + const reason = PERMISSION_CLEANUP_REASON_MESSAGES[cleanupDecision.reason]; logger.warn(`Cleared ${count} permission row(s) for account ${account.id} (user ${account.user.email ?? 'unknown'}) — fail-closed cleanup triggered by ${reason}: ${message}`); } throw error; @@ -227,20 +262,7 @@ export class AccountPermissionSyncer { logger.debug(`Syncing permissions for ${account.providerId} account (id: ${account.id}) for user ${account.user.email}...`); // Ensure the OAuth token is fresh, refreshing it if it is expired or near expiry. - // - // @note(SOU-1177) re-throwing as a RefreshTokenError here is required to flag to the caller - // (runJob) that the account's permissions should be cleared. The side-effect with this - // approach is that permissions will be cleared for any error thrown in the - // ensureFreshAccountToken path. A better approach would be to look at the response - // from the oauth call and determining if the host returned a invalid_grant. - // - // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 - let accessToken; - try { - accessToken = await ensureFreshAccountToken(account, this.db); - } catch (error) { - throw new RefreshTokenError(error instanceof Error ? error.message : 'Failed to refresh token with unknown error.'); - } + const accessToken = await ensureFreshAccountToken(account, this.db); // Get a list of all repos that the user has access to from all connected accounts. const repoIds = await (async () => { diff --git a/packages/backend/src/ee/tokenRefresh.test.ts b/packages/backend/src/ee/tokenRefresh.test.ts new file mode 100644 index 000000000..9e1a6b614 --- /dev/null +++ b/packages/backend/src/ee/tokenRefresh.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Account, PrismaClient } from '@sourcebot/db'; + +const sharedMocks = vi.hoisted(() => ({ + decryptOAuthToken: vi.fn(), + encryptOAuthToken: vi.fn(), + getIdentityProviderConfig: vi.fn(), + getTokenFromConfig: vi.fn(), +})); + +vi.mock('@sourcebot/shared', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLogger: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + })), + decryptOAuthToken: sharedMocks.decryptOAuthToken, + encryptOAuthToken: sharedMocks.encryptOAuthToken, + getIdentityProviderConfig: sharedMocks.getIdentityProviderConfig, + getTokenFromConfig: sharedMocks.getTokenFromConfig, + }; +}); + +import { ensureFreshAccountToken, exchangeRefreshToken, TokenRefreshError } from './tokenRefresh.js'; + +const credentials = { + clientId: 'client-id', + clientSecret: 'client-secret', + baseUrl: 'https://bitbucket.example.com/bitbucket', +}; + +const tokenResponse = () => new Response(JSON.stringify({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, +}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('exchangeRefreshToken', () => { + beforeEach(() => { + vi.useFakeTimers(); + sharedMocks.decryptOAuthToken.mockReset().mockImplementation(token => token); + sharedMocks.encryptOAuthToken.mockReset().mockImplementation(token => `encrypted:${token}`); + sharedMocks.getIdentityProviderConfig.mockReset().mockResolvedValue({ + type: 'bitbucket-server', + clientId: 'client-id', + clientSecret: 'client-secret', + baseUrl: credentials.baseUrl, + }); + sharedMocks.getTokenFromConfig.mockReset().mockImplementation(token => Promise.resolve(token)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + test('returns a valid token response', async () => { + const fetchMock = vi.fn().mockResolvedValue(tokenResponse()); + vi.stubGlobal('fetch', fetchMock); + + await expect(exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + )).resolves.toEqual({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://bitbucket.example.com/bitbucket/rest/oauth2/latest/token', + expect.objectContaining({ + method: 'POST', + signal: expect.any(AbortSignal), + }), + ); + }); + + test('classifies invalid_grant as a non-retryable credential rejection', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + error: 'invalid_grant', + error_description: 'The provided refresh_token is invalid', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + const error = await exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + ).catch(error => error); + + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error).toMatchObject({ + kind: 'invalid_grant', + status: 400, + oauthError: 'invalid_grant', + errorDescription: 'The provided refresh_token is invalid', + isRetryable: false, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + test('preserves the typed error through ensureFreshAccountToken', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + error: 'invalid_grant', + error_description: 'The provided refresh_token is invalid', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + const account = { + id: 'account-id', + providerId: 'bitbucket-server-idp', + providerType: 'bitbucket-server', + access_token: 'old-access-token', + refresh_token: 'old-refresh-token', + expires_at: 1, + } as Account; + const update = vi.fn().mockResolvedValue(account); + const db = { + account: { update }, + } as unknown as PrismaClient; + + const error = await ensureFreshAccountToken(account, db).catch(error => error); + + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error).toMatchObject({ + kind: 'invalid_grant', + status: 400, + oauthError: 'invalid_grant', + }); + expect(update).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith({ + where: { id: account.id }, + data: { + tokenRefreshErrorMessage: 'bitbucket-server rejected the OAuth refresh token: The provided refresh_token is invalid', + }, + }); + }); + + test('retries a transient HTTP 500 response', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + errors: ['The server could not perform this operation'], + }), { status: 500 })) + .mockResolvedValueOnce(tokenResponse()); + vi.stubGlobal('fetch', fetchMock); + + const resultPromise = exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + ); + + await vi.advanceTimersByTimeAsync(3000); + + await expect(resultPromise).resolves.toMatchObject({ + access_token: 'new-access-token', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('preserves the transient classification after retries are exhausted', async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( + new Response(JSON.stringify({ + errors: ['The server could not perform this operation'], + }), { status: 500 }), + )); + vi.stubGlobal('fetch', fetchMock); + + const resultPromise = exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + ); + resultPromise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(6000); + + const error = await resultPromise.catch(error => error); + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error).toMatchObject({ + kind: 'transient', + status: 500, + isRetryable: true, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('retries a transient network error', async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(tokenResponse()); + vi.stubGlobal('fetch', fetchMock); + + const resultPromise = exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + ); + + await vi.advanceTimersByTimeAsync(3000); + + await expect(resultPromise).resolves.toMatchObject({ + access_token: 'new-access-token', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('does not retry a non-invalid_grant OAuth rejection', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + error: 'invalid_client', + error_description: 'Client authentication failed', + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + const error = await exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + ).catch(error => error); + + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error).toMatchObject({ + kind: 'configuration', + status: 400, + oauthError: 'invalid_client', + isRetryable: false, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + test('classifies a malformed successful response as invalid_response', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + token: 'not-an-oauth-token-response', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + const error = await exchangeRefreshToken( + 'bitbucket-server', + 'old-refresh-token', + credentials, + ).catch(error => error); + + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error).toMatchObject({ + kind: 'invalid_response', + isRetryable: false, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/backend/src/ee/tokenRefresh.ts b/packages/backend/src/ee/tokenRefresh.ts index 5fe6bcbe6..718c35310 100644 --- a/packages/backend/src/ee/tokenRefresh.ts +++ b/packages/backend/src/ee/tokenRefresh.ts @@ -39,8 +39,48 @@ const OAuthTokenResponseSchema = z.object({ scope: z.string().optional(), }); +// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +const OAuthErrorResponseSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); type OAuthTokenResponse = z.infer; +export type TokenRefreshErrorKind = + | 'invalid_grant' + | 'transient' + | 'configuration' + | 'invalid_response' + | 'local_credential'; + +type TokenRefreshErrorOptions = { + kind: TokenRefreshErrorKind; + status?: number; + oauthError?: string; + errorDescription?: string; + cause?: unknown; +}; + +export class TokenRefreshError extends Error { + public readonly kind: TokenRefreshErrorKind; + public readonly status?: number; + public readonly oauthError?: string; + public readonly errorDescription?: string; + + constructor(message: string, options: TokenRefreshErrorOptions) { + super(message, { cause: options.cause }); + this.name = 'TokenRefreshError'; + this.kind = options.kind; + this.status = options.status; + this.oauthError = options.oauthError; + this.errorDescription = options.errorDescription; + } + + public get isRetryable(): boolean { + return this.kind === 'transient'; + } +} + type ProviderCredentials = { clientId: string; clientSecret: string; @@ -48,6 +88,15 @@ type ProviderCredentials = { }; const EXPIRY_BUFFER_S = 5 * 60; // 5 minutes +const TOKEN_REFRESH_TIMEOUT_MS = 15 * 1000; +const TOKEN_REFRESH_MAX_ATTEMPTS = 3; +const TOKEN_REFRESH_RETRY_BASE_DELAY_MS = 3 * 1000; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const wait = async (delayMs: number): Promise => + new Promise(resolve => setTimeout(resolve, delayMs)); /** * Ensures the OAuth access token for a given account is fresh. @@ -65,7 +114,10 @@ export const ensureFreshAccountToken = async ( db: PrismaClient, ): Promise => { if (!account.access_token) { - throw new Error(`Account ${account.id} (${account.providerId}) has no access token.`); + throw new TokenRefreshError( + `Account ${account.id} (${account.providerId}) has no access token.`, + { kind: 'local_credential' }, + ); } if (!isSupportedProvider(account.providerType)) { @@ -95,7 +147,7 @@ export const ensureFreshAccountToken = async ( const message = `Account ${account.id} (${account.providerId}) token is expired and has no refresh token.`; logger.error(message); await setTokenRefreshError(account.id, message, db); - throw new Error(message); + throw new TokenRefreshError(message, { kind: 'local_credential' }); } const refreshToken = decryptOAuthToken(account.refresh_token); @@ -103,7 +155,7 @@ export const ensureFreshAccountToken = async ( const message = `Failed to decrypt refresh token for account ${account.id} (${account.providerId}).`; logger.error(message); await setTokenRefreshError(account.id, message, db); - throw new Error(message); + throw new TokenRefreshError(message, { kind: 'local_credential' }); } logger.debug(`Refreshing OAuth token for account ${account.id} (${account.providerId})...`); @@ -112,14 +164,12 @@ export const ensureFreshAccountToken = async ( account.providerId, account.providerType, refreshToken - ); - - if (!refreshResponse) { - const message = `OAuth token refresh failed for account ${account.id} (${account.providerId}).`; - logger.error(message); + ).catch(async (error: unknown) => { + const message = getErrorMessage(error); + logger.error(`OAuth token refresh failed for account ${account.id} (${account.providerId}): ${message}`); await setTokenRefreshError(account.id, message, db); - throw new Error(message); - } + throw error; + }); const newExpiresAt = refreshResponse.expires_in ? Math.floor(Date.now() / 1000) + refreshResponse.expires_in @@ -155,13 +205,16 @@ const refreshOAuthToken = async ( providerId: string, providerType: SupportedProviderType, refreshToken: string, -): Promise => { +): Promise => { + let credentials: ProviderCredentials; + try { const idpConfig = await getIdentityProviderConfig(providerId); if (!idpConfig) { - logger.error(`No provider config found for: ${providerId}`); - return null; + throw new TokenRefreshError(`No provider config found for: ${providerId}`, { + kind: 'configuration', + }); } const linkedAccountProviderConfig = idpConfig as @@ -177,24 +230,31 @@ const refreshOAuthToken = async ( ? linkedAccountProviderConfig.baseUrl : undefined; - const result = await tryRefreshToken(providerType, refreshToken, { clientId, clientSecret, baseUrl }); - if (result) { - return result; + credentials = { clientId, clientSecret, baseUrl }; + } catch (error) { + if (error instanceof TokenRefreshError) { + throw error; } - logger.error(`Token refresh failed for ${providerId}`); - return null; - } catch (e) { - logger.error(`Error refreshing ${providerType} token:`, e); - return null; + const wrappedError = new TokenRefreshError( + `Unexpected error refreshing ${providerType} token: ${getErrorMessage(error)}`, + { + kind: 'configuration', + cause: error, + }, + ); + logger.error(wrappedError.message); + throw wrappedError; } + + return exchangeRefreshToken(providerType, refreshToken, credentials); }; -const tryRefreshToken = async ( +export const exchangeRefreshToken = async ( providerType: SupportedProviderType, refreshToken: string, credentials: ProviderCredentials, -): Promise => { +): Promise => { const { clientId, clientSecret, baseUrl } = credentials; let url: string; @@ -216,8 +276,9 @@ const tryRefreshToken = async ( } else if (providerType === 'bitbucket-cloud') { url = 'https://bitbucket.org/site/oauth2/access_token'; } else { - logger.error(`Unsupported provider for token refresh: ${providerType}`); - return null; + throw new TokenRefreshError(`Unsupported provider for token refresh: ${providerType}`, { + kind: 'configuration', + }); } // Bitbucket requires client credentials via HTTP Basic Auth rather than request body params. @@ -243,31 +304,143 @@ const tryRefreshToken = async ( bodyParams.redirect_uri = new URL('/api/auth/callback/gitlab', env.AUTH_URL).toString(); } - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - ...(useBasicAuth ? { - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, - } : {}), - }, - body: new URLSearchParams(bodyParams), - }); + let response: Response | undefined; + for (let attempt = 1; attempt <= TOKEN_REFRESH_MAX_ATTEMPTS; attempt++) { + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + ...(useBasicAuth ? { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + } : {}), + }, + body: new URLSearchParams(bodyParams), + signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw await classifyTokenRefreshErrorResponse(response, providerType); + } + + break; + } catch (error) { + const classifiedError = error instanceof TokenRefreshError + ? error + : classifyTokenRefreshFetchError(error, providerType); + + if (!classifiedError.isRetryable || attempt === TOKEN_REFRESH_MAX_ATTEMPTS) { + throw classifiedError; + } + + const delayMs = TOKEN_REFRESH_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + logger.warn( + `Transient ${providerType} token refresh failure. Waiting ${delayMs}ms before retry ${attempt}/${TOKEN_REFRESH_MAX_ATTEMPTS}: ${classifiedError.message}`, + ); + await wait(delayMs); + } + } + + if (!response) { + throw new TokenRefreshError(`${providerType} token refresh produced no response.`, { + kind: 'invalid_response', + }); + } - if (!response.ok) { - const errorText = await response.text(); - logger.error(`Failed to refresh ${providerType} token: ${response.status} ${errorText}`); - return null; + let json: unknown; + try { + json = await response.json(); + } catch (error) { + throw new TokenRefreshError(`${providerType} returned a non-JSON token response.`, { + kind: 'invalid_response', + cause: error, + }); } - const json = await response.json(); const result = OAuthTokenResponseSchema.safeParse(json); if (!result.success) { - logger.error(`Invalid OAuth token response from ${providerType}:\n${result.error.message}`); - return null; + throw new TokenRefreshError(`Invalid OAuth token response from ${providerType}: ${result.error.message}`, { + kind: 'invalid_response', + }); } return result.data; -} \ No newline at end of file +}; + +const classifyTokenRefreshFetchError = ( + error: unknown, + providerType: SupportedProviderType, +): TokenRefreshError => { + const errorName = error instanceof Error ? error.name : undefined; + const timedOut = errorName === 'TimeoutError' || errorName === 'AbortError'; + + return new TokenRefreshError( + timedOut + ? `${providerType} token refresh timed out.` + : `${providerType} token endpoint could not be reached: ${getErrorMessage(error)}`, + { + kind: 'transient', + cause: error, + }, + ); +}; + +const classifyTokenRefreshErrorResponse = async ( + response: Response, + providerType: SupportedProviderType, +): Promise => { + let oauthError: string | undefined; + let errorDescription: string | undefined; + + try { + const responseText = await response.text(); + const result = OAuthErrorResponseSchema.safeParse(JSON.parse(responseText)); + if (result.success) { + oauthError = result.data.error; + errorDescription = result.data.error_description; + } + } catch { + // Non-JSON and malformed OAuth errors are still classified by HTTP status. + } + + const details = errorDescription ? `: ${errorDescription}` : ''; + + if (oauthError === 'invalid_grant') { + return new TokenRefreshError(`${providerType} rejected the OAuth refresh token${details}`, { + kind: 'invalid_grant', + status: response.status, + oauthError, + errorDescription, + }); + } + + if ( + response.status === 408 || + response.status === 429 || + response.status >= 500 || + oauthError === 'server_error' || + oauthError === 'temporarily_unavailable' + ) { + return new TokenRefreshError( + `${providerType} token endpoint is temporarily unavailable (HTTP ${response.status})${details}`, + { + kind: 'transient', + status: response.status, + oauthError, + errorDescription, + }, + ); + } + + return new TokenRefreshError( + `${providerType} token endpoint rejected the refresh request (HTTP ${response.status})${details}`, + { + kind: 'configuration', + status: response.status, + oauthError, + errorDescription, + }, + ); +};