Fix google compose scope not gated by feature flag (#18093)

## Context
New google api scope has been introduced in
https://github.com/twentyhq/twenty/pull/17793 without being gated behind
a feature flag

Microsoft is using pre-existing Mail.ReadWrite scope there is nothing to
gate

## Before
<img width="553" height="445" alt="Screenshot 2026-02-19 at 14 57 58"
src="https://github.com/user-attachments/assets/59a4f76b-d38d-492f-b013-b6cad4091a7f"
/>


## After
<img width="535" height="392" alt="Screenshot 2026-02-19 at 14 58 44"
src="https://github.com/user-attachments/assets/0337bf15-ec30-4549-bb9d-571a982dffd8"
/>
This commit is contained in:
Weiko
2026-02-19 15:16:54 +01:00
committed by GitHub
parent e16b6d6485
commit 37d9828458
10 changed files with 87 additions and 14 deletions
@@ -12,6 +12,8 @@ import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-mod
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -27,6 +29,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly featureFlagService: FeatureFlagService,
) {
super();
}
@@ -46,7 +49,21 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
);
}
new GoogleAPIsOauthExchangeCodeForTokenStrategy(this.twentyConfigService);
const { workspaceId } =
await this.transientTokenService.verifyTransientToken(
state.transientToken,
);
const isDraftEmailEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
workspaceId,
);
new GoogleAPIsOauthExchangeCodeForTokenStrategy(
this.twentyConfigService,
isDraftEmailEnabled,
);
setRequestExtraParams(request, {
transientToken: state.transientToken,
@@ -12,6 +12,8 @@ import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -25,6 +27,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly featureFlagService: FeatureFlagService,
) {
super({
prompt: 'select_account',
@@ -68,7 +71,16 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
);
}
new GoogleAPIsOauthRequestCodeStrategy(this.twentyConfigService);
const isDraftEmailEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
workspaceId,
);
new GoogleAPIsOauthRequestCodeStrategy(
this.twentyConfigService,
isDraftEmailEnabled,
);
return (await super.canActivate(context)) as boolean;
} catch (err) {
@@ -92,8 +92,24 @@ describe('GoogleAPIScopesService', () => {
expect(result).toBe(false);
});
it('should work with the current Google API scopes', () => {
// What is currently returned by Google
it('should work with the current Google API scopes when draft email is disabled', () => {
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(false);
const result = includesExpectedScopes(actualGoogleScopes, expectedScopes);
expect(result).toBe(true);
});
it('should work with the current Google API scopes when draft email is enabled', () => {
const actualGoogleScopes = [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.readonly',
@@ -104,7 +120,7 @@ describe('GoogleAPIScopesService', () => {
'https://www.googleapis.com/auth/userinfo.profile',
'openid',
];
const expectedScopes = getGoogleApisOauthScopes();
const expectedScopes = getGoogleApisOauthScopes(true);
const result = includesExpectedScopes(actualGoogleScopes, expectedScopes);
@@ -30,6 +30,7 @@ export class GoogleAPIScopesService {
public async getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
accessToken: string,
isDraftEmailEnabled = false,
): Promise<{ scopes: string[]; isValid: boolean }> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
@@ -40,7 +41,7 @@ export class GoogleAPIScopesService {
);
const scopes = response.data.scope.split(' ');
const expectedScopes = getGoogleApisOauthScopes();
const expectedScopes = getGoogleApisOauthScopes(isDraftEmailEnabled);
return {
scopes,
@@ -10,6 +10,7 @@ import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/go
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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -181,6 +182,12 @@ describe('GoogleAPIsService', () => {
provide: getQueueToken(MessageQueue.calendarQueue),
useValue: mockCalendarQueueService,
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
],
}).compile();
@@ -13,6 +13,8 @@ import { CreateMessageChannelService } from 'src/engine/core-modules/auth/servic
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 { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -63,6 +65,7 @@ export class GoogleAPIsService {
private readonly updateConnectedAccountOnReconnectService: UpdateConnectedAccountOnReconnectService,
private readonly googleAPIScopesService: GoogleAPIScopesService,
private readonly googleApisServiceAvailabilityService: GoogleApisServiceAvailabilityService,
private readonly featureFlagService: FeatureFlagService,
) {}
async refreshGoogleRefreshToken(input: {
@@ -92,9 +95,15 @@ export class GoogleAPIsService {
'MESSAGING_PROVIDER_GMAIL_ENABLED',
);
const isDraftEmailEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
workspaceId,
);
const { scopes, isValid } =
await this.googleAPIScopesService.getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
input.accessToken,
isDraftEmailEnabled,
);
if (!isValid) {
@@ -14,8 +14,11 @@ export abstract class GoogleAPIsOauthCommonStrategy extends PassportStrategy(
Strategy,
'google-apis',
) {
constructor(twentyConfigService: TwentyConfigService) {
const scopes = getGoogleApisOauthScopes();
constructor(
twentyConfigService: TwentyConfigService,
isDraftEmailEnabled = false,
) {
const scopes = getGoogleApisOauthScopes(isDraftEmailEnabled);
super({
clientID: twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),
@@ -12,8 +12,11 @@ export type GoogleAPIScopeConfig = {
@Injectable()
export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauthCommonStrategy {
constructor(twentyConfigService: TwentyConfigService) {
super(twentyConfigService);
constructor(
twentyConfigService: TwentyConfigService,
isDraftEmailEnabled = false,
) {
super(twentyConfigService, isDraftEmailEnabled);
}
async validate(
@@ -12,8 +12,11 @@ export type GoogleAPIScopeConfig = {
@Injectable()
export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStrategy {
constructor(twentyConfigService: TwentyConfigService) {
super(twentyConfigService);
constructor(
twentyConfigService: TwentyConfigService,
isDraftEmailEnabled = false,
) {
super(twentyConfigService, isDraftEmailEnabled);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,7 +1,7 @@
/** 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 = () => {
export const getGoogleApisOauthScopes = (isDraftEmailEnabled = false) => {
return [
'email',
'profile',
@@ -9,6 +9,8 @@ export const getGoogleApisOauthScopes = () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
...(isDraftEmailEnabled
? ['https://www.googleapis.com/auth/gmail.compose']
: []),
];
};