diff --git a/apps/api/src/cloud-security/cloud-security.service.ts b/apps/api/src/cloud-security/cloud-security.service.ts index f3b5b0fcc..3a433cc02 100644 --- a/apps/api/src/cloud-security/cloud-security.service.ts +++ b/apps/api/src/cloud-security/cloud-security.service.ts @@ -139,12 +139,19 @@ export class CloudSecurityService { }); if (!accessToken) { + const refreshedConnection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { errorMessage: true }, + }); + return { success: false, provider: providerSlug, findings: [], scannedAt: new Date().toISOString(), - error: 'OAuth token expired. Please reconnect the integration.', + error: + refreshedConnection?.errorMessage ?? + 'OAuth token expired. Please reconnect the integration.', }; } diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts index 0c7227f92..a3f03d906 100644 --- a/apps/api/src/integration-platform/controllers/connections.controller.ts +++ b/apps/api/src/integration-platform/controllers/connections.controller.ts @@ -1181,14 +1181,22 @@ export class ConnectionsController { ); if (!newToken) { - // Refresh failed - connection needs to be re-established - await this.connectionService.setConnectionError( - id, - 'OAuth token expired and refresh failed. Please reconnect.', - ); + const refreshedConnection = + await this.connectionService.getConnectionForOrg( + id, + organizationId, + ); + if (refreshedConnection.status === 'error') { + throw new HttpException( + refreshedConnection.errorMessage ?? + 'Token refresh failed. Please reconnect the integration.', + HttpStatus.UNAUTHORIZED, + ); + } + throw new HttpException( - 'Token refresh failed. Please reconnect the integration.', - HttpStatus.UNAUTHORIZED, + 'Token refresh temporarily failed. Please try again.', + HttpStatus.SERVICE_UNAVAILABLE, ); } diff --git a/apps/api/src/integration-platform/controllers/connections.ensure-valid-credentials.spec.ts b/apps/api/src/integration-platform/controllers/connections.ensure-valid-credentials.spec.ts new file mode 100644 index 000000000..e2b872e3b --- /dev/null +++ b/apps/api/src/integration-platform/controllers/connections.ensure-valid-credentials.spec.ts @@ -0,0 +1,205 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionsController } from './connections.controller'; +import { ConnectionService } from '../services/connection.service'; +import { CredentialVaultService } from '../services/credential-vault.service'; +import { OAuthCredentialsService } from '../services/oauth-credentials.service'; +import { AutoCheckRunnerService } from '../services/auto-check-runner.service'; +import { ProviderRepository } from '../repositories/provider.repository'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { getManifest } from '@trycompai/integration-platform'; + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { + integration: ['create', 'read', 'update', 'delete'], + }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('@db', () => ({ + db: { + integrationProvider: { findUnique: jest.fn() }, + }, +})); + +jest.mock('@trycompai/integration-platform', () => ({ + getManifest: jest.fn(), + getAllManifests: jest.fn(), + getActiveManifests: jest.fn(), + TASK_TEMPLATE_INFO: {}, +})); + +const mockedGetManifest = getManifest as jest.MockedFunction< + typeof getManifest +>; + +type MockConnection = Awaited< + ReturnType +> & { + provider: { slug: string }; +}; + +const makeConnection = (status: 'active' | 'error'): MockConnection => ({ + id: 'conn_1', + providerId: 'prv_1', + organizationId: 'org_1', + status, + authStrategy: 'oauth2', + activeCredentialVersionId: 'icv_1', + lastSyncAt: null, + nextSyncAt: null, + syncCadence: null, + metadata: {}, + variables: {}, + errorMessage: status === 'error' ? 'Refresh token invalid' : null, + refreshLeaseUntil: null, + refreshLeaseToken: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + provider: { slug: 'gcp' }, +}); + +function expectHttpException( + error: unknown, + status: HttpStatus, +): HttpException { + if (!(error instanceof HttpException)) { + throw new Error('Expected HttpException'); + } + expect(error.getStatus()).toBe(status); + return error; +} + +async function createController() { + const connectionService = { + getConnectionForOrg: jest.fn(), + setConnectionError: jest.fn(), + }; + const credentialVaultService = { + needsRefresh: jest.fn(), + refreshOAuthTokens: jest.fn(), + getDecryptedCredentials: jest.fn(), + }; + const oauthCredentialsService = { + getCredentials: jest.fn(), + }; + const autoCheckRunnerService = { + tryAutoRunChecks: jest.fn(), + }; + const providerRepository = { + upsert: jest.fn(), + }; + const connectionRepository = { + update: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ConnectionsController], + providers: [ + { provide: ConnectionService, useValue: connectionService }, + { provide: CredentialVaultService, useValue: credentialVaultService }, + { provide: OAuthCredentialsService, useValue: oauthCredentialsService }, + { provide: AutoCheckRunnerService, useValue: autoCheckRunnerService }, + { provide: ProviderRepository, useValue: providerRepository }, + { provide: ConnectionRepository, useValue: connectionRepository }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + return { + controller: module.get(ConnectionsController), + connectionService, + credentialVaultService, + oauthCredentialsService, + }; +} + +describe('ConnectionsController ensureValidCredentials refresh failures', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetManifest.mockReturnValue({ + auth: { + type: 'oauth2', + config: { + tokenUrl: 'https://oauth2.googleapis.com/token', + refreshUrl: undefined, + clientAuthMethod: 'body', + supportsRefreshToken: true, + tokenParams: undefined, + }, + }, + } as never); + }); + + it('returns 503 without setting connection error for retryable refresh failures', async () => { + const { + controller, + connectionService, + credentialVaultService, + oauthCredentialsService, + } = await createController(); + + connectionService.getConnectionForOrg.mockResolvedValue( + makeConnection('active'), + ); + credentialVaultService.needsRefresh.mockResolvedValue(true); + credentialVaultService.refreshOAuthTokens.mockResolvedValue(null); + oauthCredentialsService.getCredentials.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + source: 'platform', + }); + + try { + await controller.ensureValidCredentials('conn_1', 'org_1'); + throw new Error('Expected ensureValidCredentials to throw'); + } catch (error) { + expectHttpException(error, HttpStatus.SERVICE_UNAVAILABLE); + } + + expect(connectionService.setConnectionError).not.toHaveBeenCalled(); + }); + + it('returns 401 when the vault has marked the connection as terminally invalid', async () => { + const { + controller, + connectionService, + credentialVaultService, + oauthCredentialsService, + } = await createController(); + + connectionService.getConnectionForOrg + .mockResolvedValueOnce(makeConnection('active')) + .mockResolvedValueOnce(makeConnection('error')); + credentialVaultService.needsRefresh.mockResolvedValue(true); + credentialVaultService.refreshOAuthTokens.mockResolvedValue(null); + oauthCredentialsService.getCredentials.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + source: 'platform', + }); + + try { + await controller.ensureValidCredentials('conn_1', 'org_1'); + throw new Error('Expected ensureValidCredentials to throw'); + } catch (error) { + const exception = expectHttpException(error, HttpStatus.UNAUTHORIZED); + expect(exception.getResponse()).toBe('Refresh token invalid'); + } + + expect(connectionService.setConnectionError).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index e62c6ca58..087e3329d 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -355,7 +355,11 @@ export class OAuthController { } // Store tokens and mark connection as active - await this.credentialVaultService.storeOAuthTokens(connection.id, tokens); + await this.credentialVaultService.storeOAuthTokens(connection.id, tokens, { + preserveExistingRefreshToken: + oauthState.providerSlug === 'gcp' || + oauthState.providerSlug === 'google-workspace', + }); // Mark cloud OAuth reconnect completion so reconnect banners clear after successful OAuth. if (manifest.category === 'Cloud') { diff --git a/apps/api/src/integration-platform/services/credential-vault.google-refresh.spec.ts b/apps/api/src/integration-platform/services/credential-vault.google-refresh.spec.ts new file mode 100644 index 000000000..dfbc06aff --- /dev/null +++ b/apps/api/src/integration-platform/services/credential-vault.google-refresh.spec.ts @@ -0,0 +1,182 @@ +jest.mock('@db', () => ({ + db: {}, +})); + +import { CredentialVaultService } from './credential-vault.service'; +import { CredentialRepository } from '../repositories/credential.repository'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import type { IntegrationConnection } from '@db'; + +const makeConnection = (): IntegrationConnection => ({ + id: 'conn_1', + providerId: 'prv_1', + organizationId: 'org_1', + status: 'active', + authStrategy: 'oauth2', + activeCredentialVersionId: 'cred_1', + lastSyncAt: null, + nextSyncAt: null, + syncCadence: null, + metadata: {}, + variables: {}, + errorMessage: null, + refreshLeaseUntil: null, + refreshLeaseToken: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}); + +describe('CredentialVaultService Google OAuth refresh handling', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not send scope when refreshing Google OAuth tokens', async () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + jest + .spyOn(credentialRepository, 'findLatestByConnection') + .mockResolvedValue(null); + jest + .spyOn(connectionRepository, 'acquireRefreshLease') + .mockResolvedValue(true); + jest + .spyOn(connectionRepository, 'releaseRefreshLease') + .mockResolvedValue(undefined); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + + jest.spyOn(service, 'getRefreshToken').mockResolvedValue('refresh-token'); + jest.spyOn(service, 'storeOAuthTokens').mockResolvedValue(undefined); + + let requestBody: BodyInit | null | undefined; + jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + requestBody = init?.body; + return new Response( + JSON.stringify({ + access_token: 'new-access', + expires_in: 3600, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }); + + const token = await service.refreshOAuthTokens('conn_1', { + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'client-id', + clientSecret: 'client-secret', + clientAuthMethod: 'body', + scope: + 'openid email profile https://www.googleapis.com/auth/cloud-platform', + }); + + expect(token).toBe('new-access'); + if (typeof requestBody !== 'string') { + throw new Error('Expected OAuth refresh body to be serialized'); + } + + const params = new URLSearchParams(requestBody); + expect(params.get('grant_type')).toBe('refresh_token'); + expect(params.get('refresh_token')).toBe('refresh-token'); + expect(params.get('client_id')).toBe('client-id'); + expect(params.get('client_secret')).toBe('client-secret'); + expect(params.has('scope')).toBe(false); + }); + + it('marks the connection as reconnect-required when no refresh token exists', async () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + jest + .spyOn(credentialRepository, 'findLatestByConnection') + .mockResolvedValue(null); + jest + .spyOn(connectionRepository, 'acquireRefreshLease') + .mockResolvedValue(true); + jest + .spyOn(connectionRepository, 'releaseRefreshLease') + .mockResolvedValue(undefined); + jest + .spyOn(connectionRepository, 'update') + .mockResolvedValue(makeConnection()); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + + jest.spyOn(service, 'getRefreshToken').mockResolvedValue(null); + const fetchSpy = jest.spyOn(globalThis, 'fetch'); + + const token = await service.refreshOAuthTokens('conn_1', { + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'client-id', + clientSecret: 'client-secret', + clientAuthMethod: 'body', + }); + + expect(token).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(connectionRepository.update).toHaveBeenCalledWith('conn_1', { + status: 'error', + errorMessage: + 'OAuth refresh token missing. Please reconnect the integration.', + }); + }); + + it('stores a specific Google session-control error when refresh returns invalid_rapt', async () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + jest + .spyOn(credentialRepository, 'findLatestByConnection') + .mockResolvedValue(null); + jest + .spyOn(connectionRepository, 'acquireRefreshLease') + .mockResolvedValue(true); + jest + .spyOn(connectionRepository, 'releaseRefreshLease') + .mockResolvedValue(undefined); + jest + .spyOn(connectionRepository, 'update') + .mockResolvedValue(makeConnection()); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + + jest.spyOn(service, 'getRefreshToken').mockResolvedValue('refresh-token'); + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ + error: 'invalid_grant', + error_description: 'reauth related error', + error_subtype: 'invalid_rapt', + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + + const token = await service.refreshOAuthTokens('conn_1', { + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'client-id', + clientSecret: 'client-secret', + clientAuthMethod: 'body', + }); + + expect(token).toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(connectionRepository.update).toHaveBeenCalledWith('conn_1', { + status: 'error', + errorMessage: + 'Google requires user reauthentication because of session-control policy (invalid_rapt). Please reconnect the integration.', + }); + }); +}); diff --git a/apps/api/src/integration-platform/services/credential-vault.google-token-storage.spec.ts b/apps/api/src/integration-platform/services/credential-vault.google-token-storage.spec.ts new file mode 100644 index 000000000..048c23310 --- /dev/null +++ b/apps/api/src/integration-platform/services/credential-vault.google-token-storage.spec.ts @@ -0,0 +1,165 @@ +jest.mock('@db', () => ({ + db: {}, +})); + +import { CredentialVaultService } from './credential-vault.service'; +import { CredentialRepository } from '../repositories/credential.repository'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import type { IntegrationConnection, IntegrationCredentialVersion } from '@db'; + +const encrypted = (value: string) => ({ + encrypted: value, + iv: 'iv', + tag: 'tag', + salt: 'salt', +}); + +const makeConnection = (): IntegrationConnection => ({ + id: 'conn_1', + providerId: 'prv_1', + organizationId: 'org_1', + status: 'active', + authStrategy: 'oauth2', + activeCredentialVersionId: 'cred_1', + lastSyncAt: null, + nextSyncAt: null, + syncCadence: null, + metadata: {}, + variables: {}, + errorMessage: null, + refreshLeaseUntil: null, + refreshLeaseToken: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}); + +const makeCredentialVersion = (): IntegrationCredentialVersion => ({ + id: 'cred_1', + connectionId: 'conn_1', + encryptedPayload: {}, + version: 1, + expiresAt: null, + rotatedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}); + +describe('CredentialVaultService Google OAuth token storage', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('preserves an existing refresh token when a new OAuth response omits one', async () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + const createSpy = jest + .spyOn(credentialRepository, 'create') + .mockResolvedValue(makeCredentialVersion()); + jest.spyOn(credentialRepository, 'deleteOldVersions').mockResolvedValue(0); + jest + .spyOn(connectionRepository, 'update') + .mockResolvedValue(makeConnection()); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + + jest.spyOn(service, 'getRefreshToken').mockResolvedValue('stored-refresh'); + jest.spyOn(service, 'encrypt').mockImplementation(async (value) => { + return encrypted(value); + }); + + await service.storeOAuthTokens( + 'conn_1', + { + access_token: 'new-access', + expires_in: 3600, + token_type: 'Bearer', + }, + { preserveExistingRefreshToken: true }, + ); + + const firstCreateCall = createSpy.mock.calls[0]; + if (!firstCreateCall) { + throw new Error('Expected credential version to be created'); + } + const [createInput] = firstCreateCall; + + expect(createInput.encryptedPayload).toMatchObject({ + refresh_token: encrypted('stored-refresh'), + access_token: encrypted('new-access'), + }); + }); + + it('does not preserve an old refresh token unless explicitly requested', async () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + const createSpy = jest + .spyOn(credentialRepository, 'create') + .mockResolvedValue(makeCredentialVersion()); + jest.spyOn(credentialRepository, 'deleteOldVersions').mockResolvedValue(0); + jest + .spyOn(connectionRepository, 'update') + .mockResolvedValue(makeConnection()); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + + const refreshTokenSpy = jest + .spyOn(service, 'getRefreshToken') + .mockResolvedValue('stored-refresh'); + jest.spyOn(service, 'encrypt').mockImplementation(async (value) => { + return encrypted(value); + }); + + await service.storeOAuthTokens('conn_1', { + access_token: 'new-access', + expires_in: 3600, + token_type: 'Bearer', + }); + + const firstCreateCall = createSpy.mock.calls[0]; + if (!firstCreateCall) { + throw new Error('Expected credential version to be created'); + } + const [createInput] = firstCreateCall; + + expect(refreshTokenSpy).not.toHaveBeenCalled(); + expect(createInput.encryptedPayload).not.toHaveProperty('refresh_token'); + }); + + it('fails closed when refresh-token preservation cannot be verified', async () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + const createSpy = jest.spyOn(credentialRepository, 'create'); + const updateSpy = jest.spyOn(connectionRepository, 'update'); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + + jest + .spyOn(service, 'getRefreshToken') + .mockRejectedValue(new Error('decrypt failed')); + jest.spyOn(service, 'encrypt').mockImplementation(async (value) => { + return encrypted(value); + }); + + await expect( + service.storeOAuthTokens( + 'conn_1', + { + access_token: 'new-access', + expires_in: 3600, + token_type: 'Bearer', + }, + { preserveExistingRefreshToken: true }, + ), + ).rejects.toThrow( + 'Unable to preserve existing refresh token for connection conn_1: decrypt failed', + ); + + expect(createSpy).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts index cf24b2bba..490f3e5d5 100644 --- a/apps/api/src/integration-platform/services/credential-vault.service.ts +++ b/apps/api/src/integration-platform/services/credential-vault.service.ts @@ -8,6 +8,11 @@ import { } from 'crypto'; import { CredentialRepository } from '../repositories/credential.repository'; import { ConnectionRepository } from '../repositories/connection.repository'; +import { + buildOAuthRefreshErrorMessage, + isTerminalOAuthRefreshFailure, + type OAuthRefreshFailure, +} from './oauth-refresh-error'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; @@ -63,6 +68,10 @@ export interface TokenRefreshConfig { refreshUrl?: string; } +interface StoreOAuthTokensOptions { + preserveExistingRefreshToken?: boolean; +} + @Injectable() export class CredentialVaultService { private readonly logger = new Logger(CredentialVaultService.name); @@ -133,6 +142,7 @@ export class CredentialVaultService { async storeOAuthTokens( connectionId: string, tokens: OAuthTokens, + options: StoreOAuthTokensOptions = {}, ): Promise { // Encrypt each token field const encryptedPayload: Record = {}; @@ -140,8 +150,27 @@ export class CredentialVaultService { if (tokens.access_token) { encryptedPayload.access_token = await this.encrypt(tokens.access_token); } - if (tokens.refresh_token) { - encryptedPayload.refresh_token = await this.encrypt(tokens.refresh_token); + + // Google may omit refresh_token on later OAuth responses. Do not replace a + // working credential version with one that can no longer refresh itself. + let refreshToken = tokens.refresh_token; + if (!refreshToken && options.preserveExistingRefreshToken) { + try { + refreshToken = (await this.getRefreshToken(connectionId)) ?? undefined; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Unable to preserve existing refresh token for connection ${connectionId}: ${reason}`, + ); + } + if (!refreshToken) { + throw new Error( + `OAuth response did not include a refresh token and no existing refresh token could be preserved for connection ${connectionId}`, + ); + } + } + if (refreshToken) { + encryptedPayload.refresh_token = await this.encrypt(refreshToken); } if (tokens.token_type) { encryptedPayload.token_type = tokens.token_type; @@ -346,7 +375,7 @@ export class CredentialVaultService { refresh_token: refreshToken, }); - if (config.scope) { + if (config.scope && this.shouldSendRefreshScope(config)) { body.set('scope', config.scope); } @@ -423,6 +452,16 @@ export class CredentialVaultService { return { token: tokens.access_token }; } + private shouldSendRefreshScope(config: TokenRefreshConfig): boolean { + const endpoint = config.refreshUrl || config.tokenUrl; + try { + const { hostname } = new URL(endpoint); + return hostname !== 'oauth2.googleapis.com'; + } catch { + return true; + } + } + /** * Refresh OAuth tokens, serialized per connection (single-flight). * @@ -558,6 +597,11 @@ export class CredentialVaultService { this.logger.warn( `No refresh token available for connection ${connectionId}`, ); + await this.connectionRepository.update(connectionId, { + status: 'error', + errorMessage: + 'OAuth refresh token missing. Please reconnect the integration.', + }); return null; } @@ -577,6 +621,15 @@ export class CredentialVaultService { return first.token; } + if (isTerminalOAuthRefreshFailure(first)) { + await this.markTerminalTokenRefreshFailure( + connectionId, + config, + first, + ); + return null; + } + // Retry once after 2 seconds for transient failures (rate limits, network blips) this.logger.warn( `Token refresh attempt 1 failed for connection ${connectionId}: HTTP ${first.status} — ${first.errorBody ?? '(no body)'}. Retrying in 2s...`, @@ -600,16 +653,12 @@ export class CredentialVaultService { `Token refresh failed for connection ${connectionId} after 2 attempts: HTTP ${second.status} — ${second.errorBody ?? '(no body)'}`, ); - if ( - second.status === 400 || - second.status === 401 || - second.status === 403 - ) { - await this.connectionRepository.update(connectionId, { - status: 'error', - errorMessage: - 'OAuth token expired. Please reconnect the integration.', - }); + if (isTerminalOAuthRefreshFailure(second)) { + await this.markTerminalTokenRefreshFailure( + connectionId, + config, + second, + ); } return null; @@ -622,6 +671,29 @@ export class CredentialVaultService { } } + private async markTerminalTokenRefreshFailure( + connectionId: string, + config: TokenRefreshConfig, + failure: OAuthRefreshFailure, + ): Promise { + await this.connectionRepository.update(connectionId, { + status: 'error', + errorMessage: buildOAuthRefreshErrorMessage({ + providerHost: this.getTokenEndpointHost(config), + failure, + }), + }); + } + + private getTokenEndpointHost(config: TokenRefreshConfig): string | undefined { + const endpoint = config.refreshUrl || config.tokenUrl; + try { + return new URL(endpoint).hostname; + } catch { + return undefined; + } + } + /** * Get a valid access token, refreshing if necessary. * This is the main method to use when making API calls. diff --git a/apps/api/src/integration-platform/services/oauth-refresh-error.ts b/apps/api/src/integration-platform/services/oauth-refresh-error.ts new file mode 100644 index 000000000..01f38c919 --- /dev/null +++ b/apps/api/src/integration-platform/services/oauth-refresh-error.ts @@ -0,0 +1,122 @@ +export interface OAuthRefreshFailure { + status?: number; + errorBody?: string; +} + +interface ParsedOAuthError { + error?: string; + errorDescription?: string; + errorSubtype?: string; +} + +const TERMINAL_HTTP_STATUSES = new Set([400, 401, 403]); +const RETRYABLE_OAUTH_ERRORS = new Set([ + 'temporarily_unavailable', + 'server_error', +]); + +function getStringField( + value: Record, + key: string, +): string | undefined { + const field = value[key]; + return typeof field === 'string' && field.trim().length > 0 + ? field.trim() + : undefined; +} + +function parseJsonOAuthError(errorBody: string): ParsedOAuthError | null { + try { + const parsed: unknown = JSON.parse(errorBody); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const body = parsed as Record; + return { + error: getStringField(body, 'error'), + errorDescription: getStringField(body, 'error_description'), + errorSubtype: getStringField(body, 'error_subtype'), + }; + } catch { + return null; + } +} + +function parseFormOAuthError(errorBody: string): ParsedOAuthError { + const params = new URLSearchParams(errorBody); + return { + error: params.get('error') ?? undefined, + errorDescription: params.get('error_description') ?? undefined, + errorSubtype: params.get('error_subtype') ?? undefined, + }; +} + +export function parseOAuthRefreshError( + errorBody?: string, +): ParsedOAuthError { + if (!errorBody) { + return {}; + } + + return parseJsonOAuthError(errorBody) ?? parseFormOAuthError(errorBody); +} + +export function isTerminalOAuthRefreshFailure( + failure: OAuthRefreshFailure, +): boolean { + if (!failure.status || !TERMINAL_HTTP_STATUSES.has(failure.status)) { + return false; + } + + const parsed = parseOAuthRefreshError(failure.errorBody); + if (parsed.error && RETRYABLE_OAUTH_ERRORS.has(parsed.error)) { + return false; + } + + return true; +} + +export function buildOAuthRefreshErrorMessage(params: { + providerHost?: string; + failure: OAuthRefreshFailure; +}): string { + const parsed = parseOAuthRefreshError(params.failure.errorBody); + const provider = params.providerHost === 'oauth2.googleapis.com' + ? 'Google' + : 'OAuth provider'; + const suffix = + parsed.errorDescription && parsed.errorDescription !== parsed.error + ? ` ${parsed.errorDescription}` + : ''; + + if (parsed.errorSubtype === 'invalid_rapt') { + return `${provider} requires user reauthentication because of session-control policy (invalid_rapt). Please reconnect the integration.`; + } + + if (parsed.error === 'invalid_grant') { + return `${provider} rejected the OAuth refresh token (invalid_grant). Please reconnect the integration.${suffix}`; + } + + if (parsed.error === 'admin_policy_enforced') { + return `${provider} admin policy blocked one or more requested OAuth scopes. Update OAuth app access policy, then reconnect.${suffix}`; + } + + if (parsed.error === 'unauthorized_client') { + return `${provider} OAuth client is not authorized for refresh tokens. Check OAuth app configuration, then reconnect.${suffix}`; + } + + if (parsed.error === 'invalid_client') { + return `${provider} OAuth client credentials were rejected. Check the client ID and secret before reconnecting.${suffix}`; + } + + if (parsed.error === 'invalid_scope') { + return `${provider} rejected the requested OAuth scopes. Check the integration OAuth scope configuration before reconnecting.${suffix}`; + } + + if (parsed.error) { + return `${provider} token refresh failed with ${parsed.error}. Please reconnect the integration.${suffix}`; + } + + return `${provider} token refresh failed with HTTP ${params.failure.status ?? 'unknown'}. Please reconnect the integration.`; +} diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index ce5da8e12..06dd59254 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -276,8 +276,7 @@ export const runTaskIntegrationChecks = task({ where: { id: connectionId }, data: { status: 'error', - errorMessage: - 'OAuth token expired. Please reconnect the integration.', + errorMessage, }, }); }