Google-scopes-handling (#12362)

# Summary

Enhanced the Google OAuth flow to better handle missing permissions and
improved user experience by redirecting to settings/account page.


## Changes
- Added new google-apis-scopes.ts service for better scope management
- Updated Google APIs auth controller for better flow control
- New tests for this logic

## User request
From @bonapara email test and need to better handle user flow during the
connect email flow

Before :
<img width="574" alt="Screenshot 2025-05-28 at 17 58 59"
src="https://github.com/user-attachments/assets/fd54625b-e211-4b2f-b76a-48bcb08b5222"
/>

After : 
<img width="1143" alt="Screenshot 2025-05-28 at 16 29 05"
src="https://github.com/user-attachments/assets/8f3d1f2c-9e02-4d25-b949-fe2b20f048f4"
/>

## Reference :
For google specialities, I added this link in the `export const
getGoogleApisOauthScopes` in order to keep that in mind

https://developers.google.com/identity/protocols/oauth2/scopes
This commit is contained in:
Guillim
2025-06-02 12:28:55 +02:00
committed by GitHub
parent 4e410db983
commit 2001041a48
9 changed files with 324 additions and 3 deletions

View File

@ -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,

View File

@ -126,7 +126,7 @@ export class GoogleAPIsAuthController {
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
},
pathname: '/verify',
pathname: '/settings/accounts',
}),
);
}

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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}`,
),
);
};

View File

@ -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<TokenInfoResponse>(
`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,
);
}
}
}

View File

@ -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: {

View File

@ -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<ObjectMetadataEntity>,
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) => {

View File

@ -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',