Skip to content

Commit 2001041

Browse files
authored
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
1 parent 4e410db commit 2001041

9 files changed

+324
-3
lines changed

packages/twenty-server/src/engine/core-modules/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/servi
1717
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
1818
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
1919
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
20+
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
2021
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
2122
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
2223
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
@@ -115,6 +116,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
115116
SamlAuthStrategy,
116117
AuthResolver,
117118
GoogleAPIsService,
119+
GoogleAPIScopesService,
118120
MicrosoftAPIsService,
119121
AppTokenService,
120122
AccessTokenService,

packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class GoogleAPIsAuthController {
126126
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
127127
customDomain: null,
128128
},
129-
pathname: '/verify',
129+
pathname: '/settings/accounts',
130130
}),
131131
);
132132
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
2+
3+
import { includesExpectedScopes } from './google-apis-scopes.service.util';
4+
5+
describe('GoogleAPIScopesService', () => {
6+
describe('includesExpectedScopes', () => {
7+
it('should return true when all expected scopes are present', () => {
8+
const scopes = [
9+
'email',
10+
'profile',
11+
'https://www.googleapis.com/auth/gmail.readonly',
12+
'https://www.googleapis.com/auth/calendar.events',
13+
];
14+
const expectedScopes = ['email', 'profile'];
15+
16+
const result = includesExpectedScopes(scopes, expectedScopes);
17+
18+
expect(result).toBe(true);
19+
});
20+
21+
it('should return false when some expected scopes are missing', () => {
22+
const scopes = ['email', 'profile'];
23+
const expectedScopes = [
24+
'email',
25+
'profile',
26+
'https://www.googleapis.com/auth/gmail.readonly',
27+
];
28+
29+
const result = includesExpectedScopes(scopes, expectedScopes);
30+
31+
expect(result).toBe(false);
32+
});
33+
34+
it('should return true when expected scopes match with userinfo prefix fallback', () => {
35+
const scopes = [
36+
'https://www.googleapis.com/auth/userinfo.email',
37+
'https://www.googleapis.com/auth/userinfo.profile',
38+
];
39+
const expectedScopes = ['email', 'profile'];
40+
41+
const result = includesExpectedScopes(scopes, expectedScopes);
42+
43+
expect(result).toBe(true);
44+
});
45+
46+
it('should return true when some scopes are direct matches and others use userinfo prefix', () => {
47+
const scopes = [
48+
'email',
49+
'https://www.googleapis.com/auth/userinfo.profile',
50+
'https://www.googleapis.com/auth/gmail.readonly',
51+
];
52+
const expectedScopes = ['email', 'profile'];
53+
54+
const result = includesExpectedScopes(scopes, expectedScopes);
55+
56+
expect(result).toBe(true);
57+
});
58+
59+
it('should return true when 0 expected scopes', () => {
60+
const scopes = ['email', 'profile'];
61+
const expectedScopes: string[] = [];
62+
63+
const result = includesExpectedScopes(scopes, expectedScopes);
64+
65+
expect(result).toBe(true);
66+
});
67+
68+
it('should return false when 0 scopes but expected scopes', () => {
69+
const scopes: string[] = [];
70+
const expectedScopes = ['email', 'profile'];
71+
72+
const result = includesExpectedScopes(scopes, expectedScopes);
73+
74+
expect(result).toBe(false);
75+
});
76+
77+
it('should return true when both empty', () => {
78+
const scopes: string[] = [];
79+
const expectedScopes: string[] = [];
80+
81+
const result = includesExpectedScopes(scopes, expectedScopes);
82+
83+
expect(result).toBe(true);
84+
});
85+
86+
it('should handle case-sensitive scope matching', () => {
87+
const scopes = ['EMAIL', 'PROFILE'];
88+
const expectedScopes = ['email', 'profile'];
89+
90+
const result = includesExpectedScopes(scopes, expectedScopes);
91+
92+
expect(result).toBe(false);
93+
});
94+
95+
it('should work with the current Google API scopes', () => {
96+
// What is currently returned by Google
97+
const actualGoogleScopes = [
98+
'https://www.googleapis.com/auth/calendar.events',
99+
'https://www.googleapis.com/auth/gmail.readonly',
100+
'https://www.googleapis.com/auth/gmail.send',
101+
'https://www.googleapis.com/auth/profile.emails.read',
102+
'https://www.googleapis.com/auth/userinfo.email',
103+
'https://www.googleapis.com/auth/userinfo.profile',
104+
'openid',
105+
];
106+
const expectedScopes = getGoogleApisOauthScopes();
107+
108+
const result = includesExpectedScopes(actualGoogleScopes, expectedScopes);
109+
110+
expect(result).toBe(true);
111+
});
112+
});
113+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
2+
3+
import { includesExpectedScopes } from './google-apis-scopes.service.util';
4+
5+
describe('GoogleAPIScopesService', () => {
6+
describe('includesExpectedScopes', () => {
7+
it('should return true when all expected scopes are present', () => {
8+
const scopes = [
9+
'email',
10+
'profile',
11+
'https://www.googleapis.com/auth/gmail.readonly',
12+
'https://www.googleapis.com/auth/calendar.events',
13+
];
14+
const expectedScopes = ['email', 'profile'];
15+
16+
const result = includesExpectedScopes(scopes, expectedScopes);
17+
18+
expect(result).toBe(true);
19+
});
20+
21+
it('should return false when some expected scopes are missing', () => {
22+
const scopes = ['email', 'profile'];
23+
const expectedScopes = [
24+
'email',
25+
'profile',
26+
'https://www.googleapis.com/auth/gmail.readonly',
27+
];
28+
29+
const result = includesExpectedScopes(scopes, expectedScopes);
30+
31+
expect(result).toBe(false);
32+
});
33+
34+
it('should return true when expected scopes match with userinfo prefix fallback', () => {
35+
const scopes = [
36+
'https://www.googleapis.com/auth/userinfo.email',
37+
'https://www.googleapis.com/auth/userinfo.profile',
38+
];
39+
const expectedScopes = ['email', 'profile'];
40+
41+
const result = includesExpectedScopes(scopes, expectedScopes);
42+
43+
expect(result).toBe(true);
44+
});
45+
46+
it('should return true when some scopes are direct matches and others use userinfo prefix', () => {
47+
const scopes = [
48+
'email',
49+
'https://www.googleapis.com/auth/userinfo.profile',
50+
'https://www.googleapis.com/auth/gmail.readonly',
51+
];
52+
const expectedScopes = ['email', 'profile'];
53+
54+
const result = includesExpectedScopes(scopes, expectedScopes);
55+
56+
expect(result).toBe(true);
57+
});
58+
59+
it('should return true when 0 expected scopes', () => {
60+
const scopes = ['email', 'profile'];
61+
const expectedScopes: string[] = [];
62+
63+
const result = includesExpectedScopes(scopes, expectedScopes);
64+
65+
expect(result).toBe(true);
66+
});
67+
68+
it('should return false when 0 scopes but expected scopes', () => {
69+
const scopes: string[] = [];
70+
const expectedScopes = ['email', 'profile'];
71+
72+
const result = includesExpectedScopes(scopes, expectedScopes);
73+
74+
expect(result).toBe(false);
75+
});
76+
77+
it('should return true when both empty', () => {
78+
const scopes: string[] = [];
79+
const expectedScopes: string[] = [];
80+
81+
const result = includesExpectedScopes(scopes, expectedScopes);
82+
83+
expect(result).toBe(true);
84+
});
85+
86+
it('should handle case-sensitive scope matching', () => {
87+
const scopes = ['EMAIL', 'PROFILE'];
88+
const expectedScopes = ['email', 'profile'];
89+
90+
const result = includesExpectedScopes(scopes, expectedScopes);
91+
92+
expect(result).toBe(false);
93+
});
94+
95+
it('should work with the current Google API scopes', () => {
96+
// What is currently returned by Google
97+
const actualGoogleScopes = [
98+
'https://www.googleapis.com/auth/calendar.events',
99+
'https://www.googleapis.com/auth/gmail.readonly',
100+
'https://www.googleapis.com/auth/gmail.send',
101+
'https://www.googleapis.com/auth/profile.emails.read',
102+
'https://www.googleapis.com/auth/userinfo.email',
103+
'https://www.googleapis.com/auth/userinfo.profile',
104+
'openid',
105+
];
106+
const expectedScopes = getGoogleApisOauthScopes();
107+
108+
const result = includesExpectedScopes(actualGoogleScopes, expectedScopes);
109+
110+
expect(result).toBe(true);
111+
});
112+
});
113+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const includesExpectedScopes = (
2+
scopes: string[],
3+
expectedScopes: string[],
4+
): boolean => {
5+
return expectedScopes.every(
6+
(expectedScope) =>
7+
scopes.includes(expectedScope) ||
8+
scopes.includes(
9+
`https://www.googleapis.com/auth/userinfo.${expectedScope}`,
10+
),
11+
);
12+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { HttpService } from '@nestjs/axios';
2+
import { Injectable } from '@nestjs/common';
3+
4+
import {
5+
AuthException,
6+
AuthExceptionCode,
7+
} from 'src/engine/core-modules/auth/auth.exception';
8+
import { includesExpectedScopes } from 'src/engine/core-modules/auth/services/google-apis-scopes.service.util';
9+
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
10+
11+
interface TokenInfoResponse {
12+
scope: string;
13+
exp: string;
14+
email: string;
15+
email_verified: string;
16+
access_type: string;
17+
client_id: string;
18+
user_id: string;
19+
aud: string;
20+
azp: string;
21+
sub: string;
22+
hd: string;
23+
}
24+
25+
@Injectable()
26+
export class GoogleAPIScopesService {
27+
constructor(private httpService: HttpService) {}
28+
29+
public async getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
30+
accessToken: string,
31+
): Promise<{ scopes: string[]; isValid: boolean }> {
32+
try {
33+
const response = await this.httpService.axiosRef.get<TokenInfoResponse>(
34+
`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`,
35+
{ timeout: 600 },
36+
);
37+
38+
const scopes = response.data.scope.split(' ');
39+
const expectedScopes = getGoogleApisOauthScopes();
40+
41+
return {
42+
scopes,
43+
isValid: includesExpectedScopes(scopes, expectedScopes),
44+
};
45+
} catch (error) {
46+
throw new AuthException(
47+
'Google account connect error: cannot read scopes from token',
48+
AuthExceptionCode.INSUFFICIENT_SCOPES,
49+
);
50+
}
51+
}
52+
}

packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
66
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
77
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
88
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
9+
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
910
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
1011
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
1112
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
@@ -114,6 +115,16 @@ describe('GoogleAPIsService', () => {
114115
resetCalendarChannels: jest.fn(),
115116
},
116117
},
118+
{
119+
provide: GoogleAPIScopesService,
120+
useValue: {
121+
getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent:
122+
jest.fn().mockResolvedValue({
123+
scopes: [],
124+
isValid: true,
125+
}),
126+
},
127+
},
117128
{
118129
provide: ResetMessageChannelService,
119130
useValue: {

packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
55
import { Repository } from 'typeorm';
66
import { v4 } from 'uuid';
77

8+
import {
9+
AuthException,
10+
AuthExceptionCode,
11+
} from 'src/engine/core-modules/auth/auth.exception';
812
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
913
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
1014
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
15+
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
1116
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
1217
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
1318
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
14-
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
1519
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
1620
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
1721
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -59,6 +63,7 @@ export class GoogleAPIsService {
5963
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
6064
@InjectRepository(ObjectMetadataEntity, 'metadata')
6165
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
66+
private readonly googleAPIScopesService: GoogleAPIScopesService,
6267
) {}
6368

6469
async refreshGoogleRefreshToken(input: {
@@ -112,7 +117,17 @@ export class GoogleAPIsService {
112117
workspaceId,
113118
});
114119

115-
const scopes = getGoogleApisOauthScopes();
120+
const { scopes, isValid } =
121+
await this.googleAPIScopesService.getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
122+
input.accessToken,
123+
);
124+
125+
if (!isValid) {
126+
throw new AuthException(
127+
'Unable to connect: Please ensure all permissions are granted',
128+
AuthExceptionCode.INSUFFICIENT_SCOPES,
129+
);
130+
}
116131

117132
await workspaceDataSource.transaction(
118133
async (manager: WorkspaceEntityManager) => {

packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/** email, profile and openid permission can be called without the https://www.googleapis.com/auth/ prefix
2+
* see https://developers.google.com/identity/protocols/oauth2/scopes
3+
*/
14
export const getGoogleApisOauthScopes = () => {
25
return [
36
'email',

0 commit comments

Comments
 (0)