diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index e8b30c0d2be..a64768faece 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -24,6 +24,7 @@ "@aws-sdk/client-sesv2": "^3.888.0", "@aws-sdk/client-sts": "3.825.0", "@aws-sdk/credential-providers": "3.825.0", + "@azure/msal-node": "^3.8.4", "@babel/preset-env": "7.26.9", "@blocknote/server-util": "^0.31.1", "@clickhouse/client": "^1.11.0", diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts index 06e86698ec2..4c95a6b50e9 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts @@ -1,67 +1,61 @@ import { Injectable } from '@nestjs/common'; -import axios, { AxiosError } from 'axios'; +import { ConfidentialClientApplication } from '@azure/msal-node'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; -import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.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; -} +import type { ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; +import { parseMsalError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util'; @Injectable() export class MicrosoftAPIRefreshAccessTokenService { - constructor(private readonly twentyConfigService: TwentyConfigService) {} + constructor(private readonly config: TwentyConfigService) {} async refreshTokens(refreshToken: string): Promise { - try { - const response = await axios.post( - 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - new URLSearchParams({ - client_id: this.twentyConfigService.get('AUTH_MICROSOFT_CLIENT_ID'), - client_secret: this.twentyConfigService.get( - 'AUTH_MICROSOFT_CLIENT_SECRET', - ), - refresh_token: refreshToken, - grant_type: 'refresh_token', - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, - ); - const responseData = response.data as MicrosoftRefreshTokenResponse; + const msalClient = new ConfidentialClientApplication({ + auth: { + clientId: this.config.get('AUTH_MICROSOFT_CLIENT_ID'), + clientSecret: this.config.get('AUTH_MICROSOFT_CLIENT_SECRET'), + authority: 'https://login.microsoftonline.com/common', + }, + }); - return { - accessToken: responseData.access_token, - refreshToken: responseData.refresh_token, - }; - } catch (error) { - if ( - error instanceof AxiosError && - error.response?.data?.error === 'invalid_grant' - ) { + try { + const response = await msalClient.acquireTokenByRefreshToken({ + refreshToken, + scopes: ['https://graph.microsoft.com/.default'], + forceCache: true, + }); + + if (!response) { throw new ConnectedAccountRefreshAccessTokenException( - `Failed to refresh Microsoft token: ${error.response?.data?.error} - ${error.response?.data?.error_description}`, + 'No response received from Microsoft token endpoint', ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN, ); } - throw error; + + return { + accessToken: response.accessToken, + refreshToken: this.extractRefreshTokenFromCache(msalClient), + }; + } catch (error) { + if (error instanceof ConnectedAccountRefreshAccessTokenException) { + throw error; + } + + throw parseMsalError(error); } } + + private extractRefreshTokenFromCache( + msalClient: ConfidentialClientApplication, + ): string { + const tokenCache = JSON.parse(msalClient.getTokenCache().serialize()); + const refreshTokenKey = Object.keys(tokenCache.RefreshToken)[0]; + + return tokenCache.RefreshToken[refreshTokenKey].secret; + } } diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts new file mode 100644 index 00000000000..7c245bc01e6 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts @@ -0,0 +1,83 @@ +import { + AuthError, + InteractionRequiredAuthError, + ServerError, +} from '@azure/msal-node'; + +import { + ConnectedAccountRefreshAccessTokenException, + ConnectedAccountRefreshAccessTokenExceptionCode, +} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; + +/** + * @see https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes + */ +const PERMANENT_AUTH_ERROR_CODES = new Set([ + 'invalid_grant', + 'invalid_client', + 'unauthorized_client', + 'invalid_request', +]); + +/** + * @see https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/src/error/ClientAuthErrorCodes.ts + */ +const TRANSIENT_AUTH_ERROR_CODES = new Set([ + 'network_error', + 'no_network_connectivity', + 'endpoints_resolution_error', + 'openid_config_error', + 'request_cannot_be_made', +]); + +export const parseMsalError = ( + error: unknown, +): ConnectedAccountRefreshAccessTokenException => { + if (error instanceof InteractionRequiredAuthError) { + return new ConnectedAccountRefreshAccessTokenException( + `Microsoft token refresh requires re-authentication: ${error.errorCode}`, + ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN, + ); + } + + if (error instanceof ServerError) { + const status = error.status; + + if (status === 429) { + return new ConnectedAccountRefreshAccessTokenException( + 'Microsoft rate limit exceeded', + ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR, + ); + } + + if (status && status >= 500 && status < 600) { + return new ConnectedAccountRefreshAccessTokenException( + `Microsoft server error (${status}): ${error.errorMessage}`, + ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR, + ); + } + } + + if (error instanceof AuthError) { + if (TRANSIENT_AUTH_ERROR_CODES.has(error.errorCode)) { + return new ConnectedAccountRefreshAccessTokenException( + `Microsoft network error: ${error.errorCode} - ${error.errorMessage}`, + ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR, + ); + } + + if (PERMANENT_AUTH_ERROR_CODES.has(error.errorCode)) { + return new ConnectedAccountRefreshAccessTokenException( + `Microsoft auth error: ${error.errorCode} - ${error.errorMessage}`, + ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN, + ); + } + } + + const message = error instanceof Error ? error.message : String(error); + + return new ConnectedAccountRefreshAccessTokenException( + `Microsoft token refresh failed: ${message}`, + ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN, + ); +}; diff --git a/yarn.lock b/yarn.lock index c7c8c92775a..16bb5c6aae2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2544,6 +2544,24 @@ __metadata: languageName: node linkType: hard +"@azure/msal-common@npm:15.13.3": + version: 15.13.3 + resolution: "@azure/msal-common@npm:15.13.3" + checksum: 10c0/0d71c31ad098153985cb918c2bda9698aae0a15b659da5c0867728313ba6743854cc112332b39ba1a187b81dfc0abba22b1c22ee6f245e511c5ecec179832b03 + languageName: node + linkType: hard + +"@azure/msal-node@npm:^3.8.4": + version: 3.8.4 + resolution: "@azure/msal-node@npm:3.8.4" + dependencies: + "@azure/msal-common": "npm:15.13.3" + jsonwebtoken: "npm:^9.0.0" + uuid: "npm:^8.3.0" + checksum: 10c0/8f61c2172c31cae156c42ada15bd7795ce7ef2b12c33e2c2e4e5802678596c53985f560cb40a93647064f1866d643f75c62699130121aa368ef9b69b87e5f071 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.21.4, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -56896,6 +56914,7 @@ __metadata: "@aws-sdk/client-sesv2": "npm:^3.888.0" "@aws-sdk/client-sts": "npm:3.825.0" "@aws-sdk/credential-providers": "npm:3.825.0" + "@azure/msal-node": "npm:^3.8.4" "@babel/preset-env": "npm:7.26.9" "@blocknote/server-util": "npm:^0.31.1" "@clickhouse/client": "npm:^1.11.0" @@ -58790,7 +58809,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.3.2": +"uuid@npm:^8.3.0, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: