fix(auth): align workspace resolution across SSO and OTP sign-in flows (#21346)

## 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 <charles@twenty.com>
This commit is contained in:
Félix Malfait
2026-06-09 11:28:11 +02:00
committed by GitHub
parent 55cbd3bfbf
commit 20ccc52424
2 changed files with 20 additions and 18 deletions
@@ -350,25 +350,16 @@ export class AuthResolver {
twoFactorAuthenticationVerificationInput: TwoFactorAuthenticationVerificationInput,
@Args('origin') origin: string,
): Promise<AuthTokens> {
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(
@@ -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;