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:
@ -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,
|
||||
|
||||
@ -126,7 +126,7 @@ export class GoogleAPIsAuthController {
|
||||
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
|
||||
customDomain: null,
|
||||
},
|
||||
pathname: '/verify',
|
||||
pathname: '/settings/accounts',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user