From 1a0588d233ab921df5bfb5e5c400b88a86308257 Mon Sep 17 00:00:00 2001 From: lasagna Date: Sun, 22 Mar 2026 08:51:10 -0700 Subject: [PATCH] fix: auto-retry Microsoft OAuth on AADSTS650051 race condition (#18405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #13008 Azure AD has a [known bug](https://learn.microsoft.com/en-us/answers/questions/5570899/our-adminconsent-url-always-fails-with-aadsts65005) where the first consent attempt for a multi-tenant app fails with `AADSTS650051` because the service principal is mid-provisioning in the target tenant. Microsoft's own engineers have acknowledged the issue with no fix ETA. This PR adds a transparent retry to the `MicrosoftOAuthGuard`: 1. When the OAuth redirect returns `AADSTS650051`, the guard parses the `state` parameter to recover the original query params (workspaceId, invite hash, locale, etc.) 2. Redirects the user back to `/auth/microsoft` with a `msRetry=1` counter 3. The full OAuth flow restarts — by this point the service principal has finished provisioning, so consent succeeds 4. Capped at 1 retry (`MAX_MICROSOFT_AUTH_RETRIES`) to prevent infinite loops. If it still fails, falls through to the normal error page From the user's perspective, they just see a brief extra redirect instead of a cryptic error page. ### Context - `AADSTS650051` is a race condition in Azure AD's service principal provisioning during multi-tenant app consent - It's distinct from missing consent (`AADSTS65001`) or admin consent required (`AADSTS90094`) - The error is transient — retrying the same flow immediately succeeds - Microsoft Q&A threads confirm this affects many multi-tenant apps, not just Twenty ### References - [Microsoft Q&A: adminconsent always fails with AADSTS650051 on first attempt](https://learn.microsoft.com/en-us/answers/questions/5570899/our-adminconsent-url-always-fails-with-aadsts65005) - [Microsoft Q&A: AADSTS650051 for multiple applications](https://learn.microsoft.com/en-us/answers/questions/5571098/when-customers-attempt-to-sign-in-and-grant-consen) ## Test plan - [ ] Deploy to a staging environment with Microsoft OAuth enabled - [ ] Have a user from a new Azure AD tenant attempt Microsoft sign-in for the first time - [ ] If AADSTS650051 occurs, verify the user is transparently retried and signs in successfully - [ ] Verify `msRetry` counter prevents infinite redirect loops (check server logs for the warn message) - [ ] Verify normal Microsoft sign-in flow (no error) is unaffected 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 --- ...soft-oauth-max-retry-attempts.constants.ts | 1 + .../__tests__/microsoft-oauth.guard.spec.ts | 103 ++++++++++++++++++ .../auth/guards/microsoft-oauth.guard.ts | 52 +++++++++ .../strategies/microsoft.auth.strategy.ts | 3 + ...crosoft-oauth-transient-error.util.spec.ts | 19 ++++ ...is-microsoft-oauth-transient-error.util.ts | 7 ++ 6 files changed, 185 insertions(+) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/constants/microsoft-oauth-max-retry-attempts.constants.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/guards/__tests__/microsoft-oauth.guard.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/utils/__tests__/is-microsoft-oauth-transient-error.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/utils/is-microsoft-oauth-transient-error.util.ts 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'); +};