diff --git a/packages/twenty-server/src/engine/core-modules/auth/constants/microsoft-oauth-max-retry-attempts.constants.ts b/packages/twenty-server/src/engine/core-modules/auth/constants/microsoft-oauth-max-retry-attempts.constants.ts new file mode 100644 index 0000000000..2d3fa6ba18 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/constants/microsoft-oauth-max-retry-attempts.constants.ts @@ -0,0 +1 @@ +export const MICROSOFT_OAUTH_MAX_RETRY_ATTEMPTS = 1; diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/__tests__/microsoft-oauth.guard.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/__tests__/microsoft-oauth.guard.spec.ts new file mode 100644 index 0000000000..e5ba532269 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/__tests__/microsoft-oauth.guard.spec.ts @@ -0,0 +1,103 @@ +import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard'; + +const AADSTS650051_ERROR = new Error( + 'AADSTS650051: The application needs to be provisioned for this tenant.', +); + +const createMockContext = () => { + const redirect = jest.fn(); + + return { + redirect, + context: { + switchToHttp: () => ({ + getResponse: () => ({ redirect }), + }), + }, + }; +}; + +const createMockRequest = (query: Record = {}) => + ({ query }) as any; + +const callHandler = ( + guard: MicrosoftOAuthGuard, + context: any, + request: any, + error: unknown, +): boolean => { + return (guard as any).handleTransientMicrosoftOAuthError( + context, + request, + error, + ); +}; + +describe('MicrosoftOAuthGuard', () => { + let guard: MicrosoftOAuthGuard; + + beforeEach(() => { + guard = new (MicrosoftOAuthGuard as any)(null, null, null); + }); + + describe('handleTransientMicrosoftOAuthError', () => { + it('should redirect to /auth/microsoft on first AADSTS650051', () => { + const { context, redirect } = createMockContext(); + const request = createMockRequest({ + state: JSON.stringify({ workspaceId: 'ws-123', locale: 'en' }), + }); + + const result = callHandler(guard, context, request, AADSTS650051_ERROR); + + expect(result).toBe(true); + expect(redirect).toHaveBeenCalledWith( + expect.stringContaining('/auth/microsoft?'), + ); + + const redirectUrl = redirect.mock.calls[0][0]; + + expect(redirectUrl).toContain('oauthRetryCount=1'); + expect(redirectUrl).toContain('workspaceId=ws-123'); + expect(redirectUrl).toContain('locale=en'); + }); + + it('should not redirect when retry count is exhausted', () => { + const { context, redirect } = createMockContext(); + const request = createMockRequest({ + state: JSON.stringify({ oauthRetryCount: 1 }), + }); + + const result = callHandler(guard, context, request, AADSTS650051_ERROR); + + expect(result).toBe(false); + expect(redirect).not.toHaveBeenCalled(); + }); + + it('should not redirect for non-transient errors', () => { + const { context, redirect } = createMockContext(); + const request = createMockRequest(); + + const result = callHandler( + guard, + context, + request, + new Error('invalid_client'), + ); + + expect(result).toBe(false); + expect(redirect).not.toHaveBeenCalled(); + }); + + it('should handle missing state without crashing', () => { + const { context, redirect } = createMockContext(); + const request = createMockRequest(); + + const result = callHandler(guard, context, request, AADSTS650051_ERROR); + + expect(result).toBe(true); + expect(redirect).toHaveBeenCalledWith( + '/auth/microsoft?oauthRetryCount=1', + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts index 0e3f2d78cc..d9f04c3942 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -2,12 +2,21 @@ import { type ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; +import { type Request } from 'express'; +import { parseJson } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; +import { MICROSOFT_OAUTH_MAX_RETRY_ATTEMPTS } from 'src/engine/core-modules/auth/constants/microsoft-oauth-max-retry-attempts.constants'; +import { type SocialSSOState } from 'src/engine/core-modules/auth/types/social-sso-state.type'; +import { isMicrosoftOAuthTransientError } from 'src/engine/core-modules/auth/utils/is-microsoft-oauth-transient-error.util'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +type SocialSSOStateWithRetry = SocialSSOState & { + oauthRetryCount?: number; +}; + @Injectable() export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { constructor( @@ -38,6 +47,10 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { return (await super.canActivate(context)) as boolean; } catch (err) { + if (this.handleTransientMicrosoftOAuthError(context, request, err)) { + return false; + } + this.guardRedirectService.dispatchErrorFromGuard( context, err, @@ -49,4 +62,43 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { return false; } } + + private handleTransientMicrosoftOAuthError( + context: ExecutionContext, + request: Request, + error: unknown, + ): boolean { + if (!isMicrosoftOAuthTransientError(error)) { + return false; + } + + const state = parseJson( + request.query.state as string, + ); + + const oauthRetryCount = Math.max(0, Number(state?.oauthRetryCount) || 0); + + if (oauthRetryCount >= MICROSOFT_OAUTH_MAX_RETRY_ATTEMPTS) { + return false; + } + + const url = new URL('/auth/microsoft', 'http://localhost'); + + url.searchParams.set('oauthRetryCount', String(oauthRetryCount + 1)); + + if (state) { + for (const [key, value] of Object.entries(state)) { + if (key !== 'oauthRetryCount' && value != null) { + url.searchParams.set(key, String(value)); + } + } + } + + context + .switchToHttp() + .getResponse() + .redirect(url.pathname + url.search); + + return true; + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index 8d5e37dd4d..884a09429b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -58,6 +58,9 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { billingCheckoutSessionState: req.query.billingCheckoutSessionState, workspacePersonalInviteToken: req.query.workspacePersonalInviteToken, action: req.query.action, + oauthRetryCount: req.query.oauthRetryCount + ? Number(req.query.oauthRetryCount) + : undefined, }), }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/is-microsoft-oauth-transient-error.util.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/is-microsoft-oauth-transient-error.util.spec.ts new file mode 100644 index 0000000000..1ec4ceb68d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/is-microsoft-oauth-transient-error.util.spec.ts @@ -0,0 +1,19 @@ +import { isMicrosoftOAuthTransientError } from 'src/engine/core-modules/auth/utils/is-microsoft-oauth-transient-error.util'; + +describe('isMicrosoftOAuthTransientError', () => { + it('should detect AADSTS650051 in the error message', () => { + const error = new Error( + 'AADSTS650051: The application needs to be provisioned for this tenant.', + ); + + expect(isMicrosoftOAuthTransientError(error)).toBe(true); + }); + + it('should reject other errors and non-Error values', () => { + expect(isMicrosoftOAuthTransientError(new Error('invalid_client'))).toBe( + false, + ); + expect(isMicrosoftOAuthTransientError('AADSTS650051')).toBe(false); + expect(isMicrosoftOAuthTransientError(null)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/is-microsoft-oauth-transient-error.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/is-microsoft-oauth-transient-error.util.ts new file mode 100644 index 0000000000..64dd1806ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/is-microsoft-oauth-transient-error.util.ts @@ -0,0 +1,7 @@ +export const isMicrosoftOAuthTransientError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + return error.message.includes('AADSTS650051'); +};