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 223e4aea8..3a39ea796 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 @@ -17,6 +17,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 { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service'; +import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes'; 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 { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service'; @@ -115,6 +116,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; SamlAuthStrategy, AuthResolver, GoogleAPIsService, + GoogleAPIScopesService, MicrosoftAPIsService, AppTokenService, AccessTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index f5bf40a3b..6d4a4ad1a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -126,7 +126,7 @@ export class GoogleAPIsAuthController { subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), customDomain: null, }, - pathname: '/verify', + pathname: '/settings/accounts', }), ); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts new file mode 100644 index 000000000..c034605d8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.spec.ts @@ -0,0 +1,113 @@ +import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; + +import { includesExpectedScopes } from './google-apis-scopes.service.util'; + +describe('GoogleAPIScopesService', () => { + describe('includesExpectedScopes', () => { + it('should return true when all expected scopes are present', () => { + const scopes = [ + 'email', + 'profile', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.events', + ]; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return false when some expected scopes are missing', () => { + const scopes = ['email', 'profile']; + const expectedScopes = [ + 'email', + 'profile', + 'https://www.googleapis.com/auth/gmail.readonly', + ]; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(false); + }); + + it('should return true when expected scopes match with userinfo prefix fallback', () => { + const scopes = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ]; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return true when some scopes are direct matches and others use userinfo prefix', () => { + const scopes = [ + 'email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/gmail.readonly', + ]; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return true when 0 expected scopes', () => { + const scopes = ['email', 'profile']; + const expectedScopes: string[] = []; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return false when 0 scopes but expected scopes', () => { + const scopes: string[] = []; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(false); + }); + + it('should return true when both empty', () => { + const scopes: string[] = []; + const expectedScopes: string[] = []; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should handle case-sensitive scope matching', () => { + const scopes = ['EMAIL', 'PROFILE']; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(false); + }); + + it('should work with the current Google API scopes', () => { + // What is currently returned by Google + const actualGoogleScopes = [ + 'https://www.googleapis.com/auth/calendar.events', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/profile.emails.read', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', + ]; + const expectedScopes = getGoogleApisOauthScopes(); + + const result = includesExpectedScopes(actualGoogleScopes, expectedScopes); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts new file mode 100644 index 000000000..c034605d8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.spec.ts @@ -0,0 +1,113 @@ +import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; + +import { includesExpectedScopes } from './google-apis-scopes.service.util'; + +describe('GoogleAPIScopesService', () => { + describe('includesExpectedScopes', () => { + it('should return true when all expected scopes are present', () => { + const scopes = [ + 'email', + 'profile', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.events', + ]; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return false when some expected scopes are missing', () => { + const scopes = ['email', 'profile']; + const expectedScopes = [ + 'email', + 'profile', + 'https://www.googleapis.com/auth/gmail.readonly', + ]; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(false); + }); + + it('should return true when expected scopes match with userinfo prefix fallback', () => { + const scopes = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ]; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return true when some scopes are direct matches and others use userinfo prefix', () => { + const scopes = [ + 'email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/gmail.readonly', + ]; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return true when 0 expected scopes', () => { + const scopes = ['email', 'profile']; + const expectedScopes: string[] = []; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should return false when 0 scopes but expected scopes', () => { + const scopes: string[] = []; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(false); + }); + + it('should return true when both empty', () => { + const scopes: string[] = []; + const expectedScopes: string[] = []; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(true); + }); + + it('should handle case-sensitive scope matching', () => { + const scopes = ['EMAIL', 'PROFILE']; + const expectedScopes = ['email', 'profile']; + + const result = includesExpectedScopes(scopes, expectedScopes); + + expect(result).toBe(false); + }); + + it('should work with the current Google API scopes', () => { + // What is currently returned by Google + const actualGoogleScopes = [ + 'https://www.googleapis.com/auth/calendar.events', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/profile.emails.read', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', + ]; + const expectedScopes = getGoogleApisOauthScopes(); + + const result = includesExpectedScopes(actualGoogleScopes, expectedScopes); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.ts new file mode 100644 index 000000000..95acd6bb0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.service.util.ts @@ -0,0 +1,12 @@ +export const includesExpectedScopes = ( + scopes: string[], + expectedScopes: string[], +): boolean => { + return expectedScopes.every( + (expectedScope) => + scopes.includes(expectedScope) || + scopes.includes( + `https://www.googleapis.com/auth/userinfo.${expectedScope}`, + ), + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts new file mode 100644 index 000000000..b154c91a4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts @@ -0,0 +1,52 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { includesExpectedScopes } from 'src/engine/core-modules/auth/services/google-apis-scopes.service.util'; +import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; + +interface TokenInfoResponse { + scope: string; + exp: string; + email: string; + email_verified: string; + access_type: string; + client_id: string; + user_id: string; + aud: string; + azp: string; + sub: string; + hd: string; +} + +@Injectable() +export class GoogleAPIScopesService { + constructor(private httpService: HttpService) {} + + public async getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent( + accessToken: string, + ): Promise<{ scopes: string[]; isValid: boolean }> { + try { + const response = await this.httpService.axiosRef.get( + `https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`, + { timeout: 600 }, + ); + + const scopes = response.data.scope.split(' '); + const expectedScopes = getGoogleApisOauthScopes(); + + return { + scopes, + isValid: includesExpectedScopes(scopes, expectedScopes), + }; + } catch (error) { + throw new AuthException( + 'Google account connect error: cannot read scopes from token', + AuthExceptionCode.INSUFFICIENT_SCOPES, + ); + } + } +} 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 8414f0c5b..c8de4bd44 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 @@ -6,6 +6,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; 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 { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service'; import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service'; @@ -114,6 +115,16 @@ describe('GoogleAPIsService', () => { resetCalendarChannels: jest.fn(), }, }, + { + provide: GoogleAPIScopesService, + useValue: { + getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent: + jest.fn().mockResolvedValue({ + scopes: [], + isValid: true, + }), + }, + }, { provide: ResetMessageChannelService, useValue: { 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 731104d4d..2df2b1173 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 @@ -5,13 +5,17 @@ import { ConnectedAccountProvider } from 'twenty-shared/types'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service'; 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 { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service'; import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service'; import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service'; -import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; 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'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; @@ -59,6 +63,7 @@ export class GoogleAPIsService { private readonly workspaceEventEmitter: WorkspaceEventEmitter, @InjectRepository(ObjectMetadataEntity, 'metadata') private readonly objectMetadataRepository: Repository, + private readonly googleAPIScopesService: GoogleAPIScopesService, ) {} async refreshGoogleRefreshToken(input: { @@ -112,7 +117,17 @@ export class GoogleAPIsService { workspaceId, }); - const scopes = getGoogleApisOauthScopes(); + const { scopes, isValid } = + await this.googleAPIScopesService.getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent( + input.accessToken, + ); + + if (!isValid) { + throw new AuthException( + 'Unable to connect: Please ensure all permissions are granted', + AuthExceptionCode.INSUFFICIENT_SCOPES, + ); + } await workspaceDataSource.transaction( async (manager: WorkspaceEntityManager) => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts index aa94f12cf..a0baf1c80 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts @@ -1,3 +1,6 @@ +/** email, profile and openid permission can be called without the https://www.googleapis.com/auth/ prefix + * see https://developers.google.com/identity/protocols/oauth2/scopes + */ export const getGoogleApisOauthScopes = () => { return [ 'email',