From 20ccc52424abb5420b82068266d0a82795b298ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 9 Jun 2026 11:28:11 +0200 Subject: [PATCH] fix(auth): align workspace resolution across SSO and OTP sign-in flows (#21346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Makes workspace resolution consistent across the SSO, OTP and login-token sign-in flows — the target workspace is derived from the authenticated principal rather than from separate request-supplied values. - **SSO callback** (`sso-auth.controller.ts`): validates the resolved workspace against the authenticating identity provider's own workspace, so every SSO session is scoped to the provider that issued it. The `workspaceInviteHash` is request-controlled and shouldn't select a different workspace than the provider. - **`getAuthTokensFromOTP`** (`auth.resolver.ts`): reuses the shared `validateWorkspaceAccess` helper already used by `getAuthTokensFromLoginToken`, so the login token and the origin-resolved workspace are checked the same way in both flows. - **SSO enablement** (`auth.service.ts`, `workspace.validate.ts`): SSO sign-in now checks the workspace operates an active SSO identity provider, matching how the other providers gate on their per-workspace settings, instead of treating SSO as unconditionally enabled. ## Tests - `auth.service.spec.ts`: SSO sign-in throws when the workspace has no active SSO identity provider; proceeds when it does. The controller and resolver spec additions were dropped from this PR; the behaviours below cover them via manual testing on `main`. `tsgo`, `oxlint` and `oxfmt` all clean on the changed files. ## How to test on main Check out this branch on top of `main` and exercise each flow against a multi-workspace setup (workspace **A** and workspace **B**, each on its own domain). **1. SSO callback is scoped to the issuing provider** (`sso-auth.controller.ts`) - Configure an SSO identity provider (SAML or OIDC) on workspace **A** and set it to **Active**. - Start an SSO sign-in for workspace **A**, but tamper with the callback so the resolved workspace points at **B** (e.g. supply a `workspaceInviteHash` belonging to **B**). - Expected: the callback is rejected with `OAUTH_ACCESS_DENIED` ("Identity provider does not belong to this workspace"). A clean callback that resolves to **A** still completes sign-in. **2. Inactive SSO provider blocks sign-in** (`auth.service.ts` / `workspace.validate.ts`) - Take workspace **A**'s SSO identity provider and set its status to something other than `Active` (e.g. inactive/draft). - Attempt SSO sign-in for **A**. - Expected: sign-in is denied with `OAUTH_ACCESS_DENIED` ("Identity provider not found"). Flipping the provider back to `Active` lets sign-in proceed. **3. OTP login token must match the origin workspace** (`getAuthTokensFromOTP` in `auth.resolver.ts`) - Enable two-factor authentication for a user who belongs to workspace **A**. - Sign in to obtain a login token scoped to **A**, then call `getAuthTokensFromOTP` (submit the OTP) from workspace **B**'s origin/domain. - Expected: the request is rejected with `FORBIDDEN_EXCEPTION` ("Token is not valid for this workspace") and no tokens are issued. Submitting the OTP from **A**'s origin issues tokens as before. --------- Co-authored-by: Charles Bochet --- .../engine/core-modules/auth/auth.resolver.ts | 25 ++++++------------- .../auth/controllers/sso-auth.controller.ts | 13 +++++++++- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 67529abe10..813e04e28f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -350,25 +350,16 @@ export class AuthResolver { twoFactorAuthenticationVerificationInput: TwoFactorAuthenticationVerificationInput, @Args('origin') origin: string, ): Promise { - const { sub: email, authProvider } = - await this.loginTokenService.verifyLoginToken( - twoFactorAuthenticationVerificationInput.loginToken, - ); - - const workspace = - await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace( - origin, - ); - - assertIsDefinedOrThrow( - workspace, - - new AuthException( - 'Workspace not found', - AuthExceptionCode.WORKSPACE_NOT_FOUND, - ), + const { + sub: email, + authProvider, + workspaceId, + } = await this.loginTokenService.verifyLoginToken( + twoFactorAuthenticationVerificationInput.loginToken, ); + const workspace = await this.validateWorkspaceAccess(origin, workspaceId); + const user = await this.userService.findUserByEmailOrThrow(email); await this.twoFactorAuthenticationService.validateStrategy( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index a814111d71..01f1b90e18 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -34,6 +34,7 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { IdentityProviderType, + SSOIdentityProviderStatus, WorkspaceSSOIdentityProviderEntity, } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; @@ -138,7 +139,10 @@ export class SSOAuthController { }); try { - if (!workspaceIdentityProvider) { + if ( + !workspaceIdentityProvider || + workspaceIdentityProvider.status !== SSOIdentityProviderStatus.Active + ) { throw new AuthException( 'Identity provider not found', AuthExceptionCode.OAUTH_ACCESS_DENIED, @@ -167,6 +171,13 @@ export class SSOAuthController { ), ); + if (currentWorkspace.id !== workspaceIdentityProvider.workspaceId) { + throw new AuthException( + 'Identity provider does not belong to this workspace', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ); + } + const oidcTokenClaims = 'oidcTokenClaims' in req.user ? req.user.oidcTokenClaims : undefined;