microsoft sync failed (#10381)

This PR is supposed to solve an issue with the syncrhonisation of
messages, specifically with microsoft driver. Microsoft calls don't need
access_Token so refreshing toekns was not implemented.

However, microsoft rely on its client which calls its refresfh_token,
and I might have missed some underlying dependency from microsoft
impelemtation so I setup the access token process to refresh it

Needs a talk before to be merged

Fix : https://github.com/twentyhq/twenty/issues/10367

EDIT:
it was a problem with microsoft making refreshtoken expire (contrarily
to google) which needs to be handled.
This commit is contained in:
Guillim
2025-03-05 16:22:51 +01:00
committed by GitHub
parent d61f48d7ee
commit 55a45c50cc
18 changed files with 332 additions and 176 deletions

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-access-token-manager/drivers/google/google-api-refresh-access-token.module';
import { RefreshAccessTokenService } from 'src/modules/connected-account/refresh-access-token-manager/services/refresh-access-token.service';
@Module({
imports: [GoogleAPIRefreshAccessTokenModule],
providers: [RefreshAccessTokenService],
exports: [RefreshAccessTokenService],
})
export class RefreshAccessTokenManagerModule {}

View File

@ -1,83 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-access-token-manager/drivers/google/services/google-api-refresh-access-token.service';
import {
RefreshAccessTokenException,
RefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-access-token-manager/exceptions/refresh-access-token.exception';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class RefreshAccessTokenService {
constructor(
private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService,
private readonly twentyORMManager: TwentyORMManager,
) {}
async refreshAndSaveAccessToken(
connectedAccount: ConnectedAccountWorkspaceEntity,
workspaceId: string,
): Promise<string> {
const refreshToken = connectedAccount.refreshToken;
let accessToken: string;
if (!refreshToken) {
throw new RefreshAccessTokenException(
`No refresh token found for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
RefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND,
);
}
switch (connectedAccount.provider) {
case 'microsoft':
return '';
case 'google': {
try {
accessToken = await this.refreshAccessToken(
connectedAccount,
refreshToken,
);
} catch (error) {
throw new RefreshAccessTokenException(
`Error refreshing access token for connected account ${connectedAccount.id} in workspace ${workspaceId}: ${error.message}`,
RefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
);
}
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
await connectedAccountRepository.update(
{ id: connectedAccount.id },
{
accessToken,
},
);
return accessToken;
}
default:
throw new Error('Provider not supported for access token refresh');
}
}
async refreshAccessToken(
connectedAccount: ConnectedAccountWorkspaceEntity,
refreshToken: string,
): Promise<string> {
switch (connectedAccount.provider) {
case 'google':
return this.googleAPIRefreshAccessTokenService.refreshAccessToken(
refreshToken,
);
default:
throw new RefreshAccessTokenException(
`Provider ${connectedAccount.provider} is not supported`,
RefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module';
import { MicrosoftAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/microsoft-api-refresh-access-token.module';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@Module({
imports: [
GoogleAPIRefreshAccessTokenModule,
MicrosoftAPIRefreshAccessTokenModule,
],
providers: [ConnectedAccountRefreshTokensService],
exports: [ConnectedAccountRefreshTokensService],
})
export class RefreshTokensManagerModule {}

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-access-token-manager/drivers/google/services/google-api-refresh-access-token.service';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-access-token.service';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
@Module({

View File

@ -1,15 +1,27 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { z } from 'zod';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type GoogleTokens = {
accessToken: string;
};
interface GoogleRefreshTokenResponse {
access_token: string;
id_token?: string;
token_type?: string;
expires_in?: number;
scope?: string;
}
@Injectable()
export class GoogleAPIRefreshAccessTokenService {
constructor(private readonly environmentService: EnvironmentService) {}
async refreshAccessToken(refreshToken: string): Promise<string> {
const response = await axios.post(
async refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
const response = await axios.post<GoogleRefreshTokenResponse>(
'https://oauth2.googleapis.com/token',
{
client_id: this.environmentService.get('AUTH_GOOGLE_CLIENT_ID'),
@ -24,6 +36,10 @@ export class GoogleAPIRefreshAccessTokenService {
},
);
return response.data.access_token;
z.string().parse(response.data.access_token);
return {
accessToken: response.data.access_token,
};
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
@Module({
imports: [EnvironmentModule],
providers: [MicrosoftAPIRefreshAccessTokenService],
exports: [MicrosoftAPIRefreshAccessTokenService],
})
export class MicrosoftAPIRefreshAccessTokenModule {}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { z } from 'zod';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type MicrosoftTokens = {
accessToken: string;
refreshToken: string;
};
interface MicrosoftRefreshTokenResponse {
access_token: string;
refresh_token: string;
scope: string;
token_type: string;
expires_in: number;
id_token?: string;
}
@Injectable()
export class MicrosoftAPIRefreshAccessTokenService {
constructor(private readonly environmentService: EnvironmentService) {}
async refreshTokens(refreshToken: string): Promise<MicrosoftTokens> {
const response = await axios.post<MicrosoftRefreshTokenResponse>(
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
new URLSearchParams({
client_id: this.environmentService.get('AUTH_MICROSOFT_CLIENT_ID'),
client_secret: this.environmentService.get(
'AUTH_MICROSOFT_CLIENT_SECRET',
),
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
z.object({
access_token: z.string(),
refresh_token: z.string(),
}).parse(response.data);
return {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
};
}
}

View File

@ -1,12 +1,15 @@
import { CustomException } from 'src/utils/custom-exception';
export class RefreshAccessTokenException extends CustomException {
constructor(message: string, code: RefreshAccessTokenExceptionCode) {
export class ConnectedAccountRefreshAccessTokenException extends CustomException {
constructor(
message: string,
code: ConnectedAccountRefreshAccessTokenExceptionCode,
) {
super(message, code);
}
}
export enum RefreshAccessTokenExceptionCode {
export enum ConnectedAccountRefreshAccessTokenExceptionCode {
REFRESH_TOKEN_NOT_FOUND = 'REFRESH_TOKEN_NOT_FOUND',
REFRESH_ACCESS_TOKEN_FAILED = 'REFRESH_ACCESS_TOKEN_FAILED',
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',

View File

@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { assertUnreachable, ConnectedAccountProvider } from 'twenty-shared';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import {
GoogleAPIRefreshAccessTokenService,
GoogleTokens,
} from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-access-token.service';
import {
MicrosoftAPIRefreshAccessTokenService,
MicrosoftTokens,
} from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type ConnectedAccountTokens = GoogleTokens | MicrosoftTokens;
@Injectable()
export class ConnectedAccountRefreshTokensService {
constructor(
private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService,
private readonly microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService,
private readonly twentyORMManager: TwentyORMManager,
) {}
async refreshAndSaveTokens(
connectedAccount: ConnectedAccountWorkspaceEntity,
workspaceId: string,
): Promise<string> {
const refreshToken = connectedAccount.refreshToken;
if (!refreshToken) {
throw new ConnectedAccountRefreshAccessTokenException(
`No refresh token found for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_TOKEN_NOT_FOUND,
);
}
const connectedAccountTokens = await this.refreshTokens(
connectedAccount,
refreshToken,
workspaceId,
);
try {
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
await connectedAccountRepository.update(
{ id: connectedAccount.id },
connectedAccountTokens,
);
} catch (error) {
throw new Error(
`Error saving the new tokens for connected account ${connectedAccount.id} in workspace ${workspaceId}: ${error.message} `,
);
}
return connectedAccountTokens.accessToken;
}
async refreshTokens(
connectedAccount: ConnectedAccountWorkspaceEntity,
refreshToken: string,
workspaceId: string,
): Promise<ConnectedAccountTokens> {
try {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.googleAPIRefreshAccessTokenService.refreshAccessToken(
refreshToken,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftAPIRefreshAccessTokenService.refreshTokens(
refreshToken,
);
default:
return assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported`,
);
}
} catch (error) {
throw new ConnectedAccountRefreshAccessTokenException(
`Error refreshing tokens for connected account ${connectedAccount.id} in workspace ${workspaceId}: ${error.message} ${error?.response?.data?.error_description}`,
ConnectedAccountRefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
);
}
}
}