From 1979e013e99783c83fabbbe60bfed96e74b71a27 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:19:03 +0530 Subject: [PATCH] Google OAuth check real permissions before creating channels (#17714) --- .../engine/core-modules/auth/auth.module.ts | 2 + ...-apis-service-availability.service.spec.ts | 264 ++++++++++++++++++ ...oogle-apis-service-availability.service.ts | 133 +++++++++ .../auth/services/google-apis.service.spec.ts | 14 +- .../auth/services/google-apis.service.ts | 117 ++++---- 5 files changed, 480 insertions(+), 50 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index e69f375ef8..e22f104e61 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -19,6 +19,7 @@ import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/servi import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes'; +import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; @@ -131,6 +132,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; AuthResolver, GoogleAPIsService, GoogleAPIScopesService, + GoogleApisServiceAvailabilityService, MicrosoftAPIsService, AppTokenService, AccessTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.spec.ts new file mode 100644 index 0000000000..223f927092 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.spec.ts @@ -0,0 +1,264 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { google } from 'googleapis'; + +import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +jest.mock('googleapis', () => ({ + google: { + auth: { + OAuth2: jest.fn().mockImplementation(() => ({ + setCredentials: jest.fn(), + })), + }, + gmail: jest.fn(), + calendar: jest.fn(), + }, +})); + +describe('GoogleApisServiceAvailabilityService', () => { + let service: GoogleApisServiceAvailabilityService; + let mockTwentyConfigService: { get: jest.Mock }; + + beforeEach(async () => { + mockTwentyConfigService = { + get: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoogleApisServiceAvailabilityService, + { + provide: TwentyConfigService, + useValue: mockTwentyConfigService, + }, + ], + }).compile(); + + service = module.get( + GoogleApisServiceAvailabilityService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('checkServicesAvailability', () => { + it('should return both services available when Gmail and Calendar are enabled and accessible', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'AUTH_GOOGLE_CLIENT_ID') return 'client-id'; + if (key === 'AUTH_GOOGLE_CLIENT_SECRET') return 'client-secret'; + if (key === 'MESSAGING_PROVIDER_GMAIL_ENABLED') return true; + if (key === 'CALENDAR_PROVIDER_GOOGLE_ENABLED') return true; + + return undefined; + }); + + const mockGmailClient = { + users: { + getProfile: jest.fn().mockResolvedValue({ data: {} }), + }, + }; + + const mockCalendarClient = { + events: { + list: jest.fn().mockResolvedValue({ data: {} }), + }, + }; + + (google.gmail as jest.Mock).mockReturnValue(mockGmailClient); + (google.calendar as jest.Mock).mockReturnValue(mockCalendarClient); + + const result = await service.checkServicesAvailability('access-token'); + + expect(result).toEqual({ + isMessagingAvailable: true, + isCalendarAvailable: true, + }); + }); + + it('should return messaging unavailable when messaging provider is disabled', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'AUTH_GOOGLE_CLIENT_ID') return 'client-id'; + if (key === 'AUTH_GOOGLE_CLIENT_SECRET') return 'client-secret'; + if (key === 'MESSAGING_PROVIDER_GMAIL_ENABLED') return false; + if (key === 'CALENDAR_PROVIDER_GOOGLE_ENABLED') return true; + + return undefined; + }); + + const mockCalendarClient = { + events: { + list: jest.fn().mockResolvedValue({ data: {} }), + }, + }; + + (google.calendar as jest.Mock).mockReturnValue(mockCalendarClient); + + const result = await service.checkServicesAvailability('access-token'); + + expect(result).toEqual({ + isMessagingAvailable: false, + isCalendarAvailable: true, + }); + }); + + it('should return messaging unavailable when messaging service is not enabled in Google Workspace', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'AUTH_GOOGLE_CLIENT_ID') return 'client-id'; + if (key === 'AUTH_GOOGLE_CLIENT_SECRET') return 'client-secret'; + if (key === 'MESSAGING_PROVIDER_GMAIL_ENABLED') return true; + if (key === 'CALENDAR_PROVIDER_GOOGLE_ENABLED') return true; + + return undefined; + }); + + const gmailServiceNotEnabledError = { + response: { + status: 400, + data: { + error: { + code: 400, + message: 'Mail service not enabled', + errors: [ + { + message: 'Mail service not enabled', + domain: 'global', + reason: 'failedPrecondition', + }, + ], + status: 'FAILED_PRECONDITION', + }, + }, + }, + }; + + const mockGmailClient = { + users: { + getProfile: jest.fn().mockRejectedValue(gmailServiceNotEnabledError), + }, + }; + + const mockCalendarClient = { + events: { + list: jest.fn().mockResolvedValue({ data: {} }), + }, + }; + + (google.gmail as jest.Mock).mockReturnValue(mockGmailClient); + (google.calendar as jest.Mock).mockReturnValue(mockCalendarClient); + + const result = await service.checkServicesAvailability('access-token'); + + expect(result).toEqual({ + isMessagingAvailable: false, + isCalendarAvailable: true, + }); + }); + + it('should return Calendar unavailable when Calendar service is not enabled in Google Workspace', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'AUTH_GOOGLE_CLIENT_ID') return 'client-id'; + if (key === 'AUTH_GOOGLE_CLIENT_SECRET') return 'client-secret'; + if (key === 'MESSAGING_PROVIDER_GMAIL_ENABLED') return true; + if (key === 'CALENDAR_PROVIDER_GOOGLE_ENABLED') return true; + + return undefined; + }); + + const calendarServiceNotEnabledError = { + response: { + status: 400, + data: { + error: { + code: 400, + message: 'Calendar service not enabled', + errors: [ + { + message: 'Calendar service not enabled', + domain: 'global', + reason: 'failedPrecondition', + }, + ], + status: 'FAILED_PRECONDITION', + }, + }, + }, + }; + + const mockGmailClient = { + users: { + getProfile: jest.fn().mockResolvedValue({ data: {} }), + }, + }; + + const mockCalendarClient = { + events: { + list: jest.fn().mockRejectedValue(calendarServiceNotEnabledError), + }, + }; + + (google.gmail as jest.Mock).mockReturnValue(mockGmailClient); + (google.calendar as jest.Mock).mockReturnValue(mockCalendarClient); + + const result = await service.checkServicesAvailability('access-token'); + + expect(result).toEqual({ + isMessagingAvailable: true, + isCalendarAvailable: false, + }); + }); + + it('should throw error for non-service-availability related errors', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'AUTH_GOOGLE_CLIENT_ID') return 'client-id'; + if (key === 'AUTH_GOOGLE_CLIENT_SECRET') return 'client-secret'; + if (key === 'MESSAGING_PROVIDER_GMAIL_ENABLED') return true; + if (key === 'CALENDAR_PROVIDER_GOOGLE_ENABLED') return true; + + return undefined; + }); + + const rateLimitError = { + response: { + status: 429, + data: { + error: { + code: 429, + message: 'Rate Limit Exceeded', + errors: [ + { + message: 'Rate Limit Exceeded', + domain: 'usageLimits', + reason: 'rateLimitExceeded', + }, + ], + status: 'RESOURCE_EXHAUSTED', + }, + }, + }, + }; + + const mockGmailClient = { + users: { + getProfile: jest.fn().mockRejectedValue(rateLimitError), + }, + }; + + const mockCalendarClient = { + events: { + list: jest.fn().mockResolvedValue({ data: {} }), + }, + }; + + (google.gmail as jest.Mock).mockReturnValue(mockGmailClient); + (google.calendar as jest.Mock).mockReturnValue(mockCalendarClient); + + await expect( + service.checkServicesAvailability('access-token'), + ).rejects.toMatchObject(rateLimitError); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.ts new file mode 100644 index 0000000000..78f96aea8a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-service-availability.service.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { google } from 'googleapis'; + +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +export type GoogleApisServiceAvailability = { + isMessagingAvailable: boolean; + isCalendarAvailable: boolean; +}; + +@Injectable() +export class GoogleApisServiceAvailabilityService { + private readonly logger = new Logger( + GoogleApisServiceAvailabilityService.name, + ); + + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + async checkServicesAvailability( + accessToken: string, + ): Promise { + const oAuth2Client = new google.auth.OAuth2( + this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'), + this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'), + ); + + oAuth2Client.setCredentials({ + access_token: accessToken, + }); + + const [isMessagingAvailable, isCalendarAvailable] = await Promise.all([ + this.checkMessagingAvailability(oAuth2Client), + this.checkCalendarAvailability(oAuth2Client), + ]); + + return { + isMessagingAvailable, + isCalendarAvailable, + }; + } + + private async checkMessagingAvailability( + oAuth2Client: InstanceType, + ): Promise { + if (!this.twentyConfigService.get('MESSAGING_PROVIDER_GMAIL_ENABLED')) { + return false; + } + + try { + const gmailClient = google.gmail({ + version: 'v1', + auth: oAuth2Client, + }); + + await gmailClient.users.getProfile({ userId: 'me' }); + + return true; + } catch (error) { + if (this.isServiceNotEnabledError(error)) { + this.logger.log( + 'Messaging service is not enabled for this Google Workspace account', + ); + + return false; + } + + this.logger.error('Error checking messaging availability', error); + + throw error; + } + } + + private async checkCalendarAvailability( + oAuth2Client: InstanceType, + ): Promise { + if (!this.twentyConfigService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')) { + return false; + } + + try { + const calendarClient = google.calendar({ + version: 'v3', + auth: oAuth2Client, + }); + + await calendarClient.events.list({ + calendarId: 'primary', + maxResults: 1, + }); + + return true; + } catch (error) { + if (this.isServiceNotEnabledError(error)) { + this.logger.log( + 'Calendar service is not enabled for this Google Workspace account', + ); + + return false; + } + + this.logger.error('Error checking Calendar availability', error); + + throw error; + } + } + + private isServiceNotEnabledError(error: unknown): boolean { + const errorResponse = ( + error as { response?: { data?: { error?: unknown } } } + )?.response?.data?.error; + + if (!errorResponse || typeof errorResponse !== 'object') { + return false; + } + + const gmailError = errorResponse as { + errors?: Array<{ reason?: string; message?: string }>; + }; + + const firstError = gmailError.errors?.[0]; + + if (!firstError) { + return false; + } + + const isFailedPrecondition = firstError.reason === 'failedPrecondition'; + const isServiceNotEnabled = + firstError.message?.includes('service not enabled') ?? false; + + return isFailedPrecondition && isServiceNotEnabled; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts index af5ef9f257..f2ea715809 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts @@ -7,6 +7,7 @@ import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/servi import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes'; +import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -127,6 +128,15 @@ describe('GoogleAPIsService', () => { }), }, }, + { + provide: GoogleApisServiceAvailabilityService, + useValue: { + checkServicesAvailability: jest.fn().mockResolvedValue({ + isMessagingAvailable: true, + isCalendarAvailable: true, + }), + }, + }, { provide: MessageChannelSyncStatusService, useValue: { @@ -238,11 +248,11 @@ describe('GoogleAPIsService', () => { expect( calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending, - ).toHaveBeenCalledWith([existingConnectedAccount.id], 'workspace-id'); + ).toHaveBeenCalledWith([failedCalendarChannel.id], 'workspace-id'); expect( messagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending, - ).toHaveBeenCalledWith([existingConnectedAccount.id], 'workspace-id'); + ).not.toHaveBeenCalled(); expect( createMessageChannelService.createMessageChannel, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts index b880c4791a..01ddbe0bcd 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts @@ -11,6 +11,7 @@ import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/servi import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service'; import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service'; import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes'; +import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service'; import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -34,6 +35,7 @@ import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-acco import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; import { MessageChannelSyncStage, + MessageChannelSyncStatus, type MessageChannelVisibility, type MessageChannelWorkspaceEntity, } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; @@ -60,6 +62,7 @@ export class GoogleAPIsService { private readonly createConnectedAccountService: CreateConnectedAccountService, private readonly updateConnectedAccountOnReconnectService: UpdateConnectedAccountOnReconnectService, private readonly googleAPIScopesService: GoogleAPIScopesService, + private readonly googleApisServiceAvailabilityService: GoogleApisServiceAvailabilityService, ) {} async refreshGoogleRefreshToken(input: { @@ -85,6 +88,10 @@ export class GoogleAPIsService { 'CALENDAR_PROVIDER_GOOGLE_ENABLED', ); + const isMessagingEnabled = this.twentyConfigService.get( + 'MESSAGING_PROVIDER_GMAIL_ENABLED', + ); + const { scopes, isValid } = await this.googleAPIScopesService.getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent( input.accessToken, @@ -97,6 +104,11 @@ export class GoogleAPIsService { ); } + const { isMessagingAvailable, isCalendarAvailable } = + await this.googleApisServiceAvailabilityService.checkServicesAvailability( + input.accessToken, + ); + const authContext = buildSystemAuthContext(workspaceId); return this.globalWorkspaceOrmManager.executeInWorkspaceContext( @@ -144,16 +156,18 @@ export class GoogleAPIsService { manager, }); - await this.createMessageChannelService.createMessageChannel({ - workspaceId, - connectedAccountId: newOrExistingConnectedAccountId, - handle, - messageVisibility, - manager, - skipMessageChannelConfiguration, - }); + if (isMessagingEnabled && isMessagingAvailable) { + await this.createMessageChannelService.createMessageChannel({ + workspaceId, + connectedAccountId: newOrExistingConnectedAccountId, + handle, + messageVisibility, + manager, + skipMessageChannelConfiguration, + }); + } - if (isCalendarEnabled) { + if (isCalendarEnabled && isCalendarAvailable) { await this.createCalendarChannelService.createCalendarChannel({ workspaceId, connectedAccountId: newOrExistingConnectedAccountId, @@ -195,62 +209,69 @@ export class GoogleAPIsService { workspaceId, newOrExistingConnectedAccountId, ); - - await this.messagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending( - [newOrExistingConnectedAccountId], - workspaceId, - ); - - await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending( - [newOrExistingConnectedAccountId], - workspaceId, - ); } }, ); - if (this.twentyConfigService.get('MESSAGING_PROVIDER_GMAIL_ENABLED')) { + if (isMessagingEnabled) { const messageChannels = await messageChannelRepository.find({ - where: { - connectedAccountId: newOrExistingConnectedAccountId, - }, + where: { connectedAccountId: newOrExistingConnectedAccountId }, }); - for (const messageChannel of messageChannels) { - if ( - messageChannel.syncStage !== - MessageChannelSyncStage.PENDING_CONFIGURATION - ) { - await this.messageQueueService.add( - MessagingMessageListFetchJob.name, - { + if (!isMessagingAvailable && messageChannels.length > 0) { + await this.messagingChannelSyncStatusService.markAsFailed( + messageChannels.map((channel) => channel.id), + workspaceId, + MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS, + ); + } + + if (isMessagingAvailable) { + for (const messageChannel of messageChannels) { + if ( + messageChannel.syncStage !== + MessageChannelSyncStage.PENDING_CONFIGURATION + ) { + await this.messagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending( + [messageChannel.id], workspaceId, - messageChannelId: messageChannel.id, - }, - ); + ); + await this.messageQueueService.add( + MessagingMessageListFetchJob.name, + { workspaceId, messageChannelId: messageChannel.id }, + ); + } } } } if (isCalendarEnabled) { const calendarChannels = await calendarChannelRepository.find({ - where: { - connectedAccountId: newOrExistingConnectedAccountId, - }, + where: { connectedAccountId: newOrExistingConnectedAccountId }, }); - for (const calendarChannel of calendarChannels) { - if ( - calendarChannel.syncStage !== - CalendarChannelSyncStage.PENDING_CONFIGURATION - ) { - await this.calendarQueueService.add( - CalendarEventListFetchJob.name, - { - calendarChannelId: calendarChannel.id, + if (!isCalendarAvailable && calendarChannels.length > 0) { + await this.calendarChannelSyncStatusService.markAsFailedInsufficientPermissionsAndFlushCalendarEventsToImport( + calendarChannels.map((channel) => channel.id), + workspaceId, + ); + } + + if (isCalendarAvailable) { + for (const calendarChannel of calendarChannels) { + if ( + calendarChannel.syncStage !== + CalendarChannelSyncStage.PENDING_CONFIGURATION + ) { + await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending( + [calendarChannel.id], workspaceId, - }, - ); + ); + await this.calendarQueueService.add( + CalendarEventListFetchJob.name, + { workspaceId, calendarChannelId: calendarChannel.id }, + ); + } } } }