Google OAuth check real permissions before creating channels (#17714)

This commit is contained in:
neo773
2026-02-10 03:19:03 +05:30
committed by GitHub
parent 3747005fd5
commit 1979e013e9
5 changed files with 480 additions and 50 deletions
@@ -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,
@@ -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>(
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);
});
});
});
@@ -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<GoogleApisServiceAvailability> {
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<typeof google.auth.OAuth2>,
): Promise<boolean> {
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<typeof google.auth.OAuth2>,
): Promise<boolean> {
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;
}
}
@@ -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,
@@ -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<MessagingMessageListFetchJobData>(
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<MessagingMessageListFetchJobData>(
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<CalendarEventListFetchJobData>(
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<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
{ workspaceId, calendarChannelId: calendarChannel.id },
);
}
}
}
}