Introduce SSO bypass permission. (#15417)

Closes [Core Issue
#1772](https://github.com/twentyhq/core-team-issues/issues/1772).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces SSO bypass with a new permission flag and workspace-level
provider toggles, enabling permitted users to log in via
Google/Microsoft/Password when SSO-only, with backend enforcement and
frontend UI/hooks/queries.
> 
> - **Backend**:
> - **Permission & Enforcement**: Add `PermissionFlagType.SSO_BYPASS`;
update `AuthService` to allow login via non-SSO providers when workspace
bypass is enabled and user has `SSO_BYPASS`.
> - **Workspace Model**: Add `isGoogleAuthBypassEnabled`,
`isMicrosoftAuthBypassEnabled`, `isPasswordAuthBypassEnabled`
(migration, entity, update input, service validation).
> - **Public API**: Extend `PublicWorkspaceDataOutput` with
`authBypassProviders`; resolver computes it; permissions defaults
include `SSO_BYPASS`.
> - **Frontend**:
> - **GraphQL/State**: Generate new types/fields; add
`authBypassProviders` to `GetPublicWorkspaceDataByDomain`; new states
`workspaceAuthBypassProvidersState`, `workspaceBypassModeState`.
> - **Auth UI/Logic**: Add `useWorkspaceBypass`; update sign-in form and
footer to offer "Bypass SSO" and use merged providers when enabled;
remove auto-redirect when single SSO.
> - **Settings**: Add Security section to toggle bypass methods per
provider; conditionally show Change Password via `useCanChangePassword`.
> - **Tests/Mocks**: Update mocks and tests to include bypass
flags/providers.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8c393b2bad387fb6e8b8f40027f8637dd6e85723. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdullah.
2025-11-03 15:40:09 +05:00
committed by GitHub
parent 604b3e50de
commit 5b2950c43a
34 changed files with 803 additions and 58 deletions
@@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { AuthService } from './auth.service';
@@ -44,6 +45,10 @@ describe('AuthService', () => {
let authSsoService: AuthSsoService;
let userWorkspaceService: UserWorkspaceService;
let workspaceInvitationService: WorkspaceInvitationService;
let permissionsService: PermissionsService;
let signInUpServiceMock: jest.Mocked<
Pick<SignInUpService, 'validatePassword'>
>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -94,7 +99,10 @@ describe('AuthService', () => {
},
{
provide: SignInUpService,
useValue: {},
useValue: {
validatePassword: jest.fn().mockResolvedValue(undefined),
generateHash: jest.fn(),
},
},
{
provide: TwentyConfigService,
@@ -153,6 +161,14 @@ describe('AuthService', () => {
provide: AuditService,
useValue: {},
},
{
provide: PermissionsService,
useValue: {
userHasWorkspaceSettingPermission: jest
.fn()
.mockResolvedValue(false),
},
},
],
}).compile();
@@ -170,10 +186,15 @@ describe('AuthService', () => {
userRepository = module.get<Repository<UserEntity>>(
getRepositoryToken(UserEntity),
);
permissionsService = module.get<PermissionsService>(PermissionsService);
signInUpServiceMock = module.get(SignInUpService) as jest.Mocked<
Pick<SignInUpService, 'validatePassword'>
>;
});
beforeEach(() => {
twentyConfigServiceGetMock.mockReturnValue(false);
signInUpServiceMock.validatePassword.mockClear();
});
it('should be defined', async () => {
@@ -216,6 +237,92 @@ describe('AuthService', () => {
});
});
it('allows password login through SSO bypass when user has permission', async () => {
const workspace = {
id: 'workspace-id',
isPasswordAuthEnabled: false,
isPasswordAuthBypassEnabled: true,
} as WorkspaceEntity;
const userEntity = {
id: 'user-id',
email: 'email',
passwordHash: 'password-hash',
userWorkspaces: [
{
id: 'user-workspace-id',
workspaceId: workspace.id,
} as any,
],
} as unknown as UserEntity;
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity);
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockResolvedValueOnce({ id: 'user-workspace-id' } as any);
jest
.spyOn(permissionsService, 'userHasWorkspaceSettingPermission')
.mockResolvedValueOnce(true);
const response = await service.validateLoginWithPassword(
{
email: 'email',
password: 'password',
captchaToken: 'captcha-token',
},
workspace,
);
expect(response).toBe(userEntity);
});
it('throws when bypass permission is missing for disabled password auth', async () => {
const workspace = {
id: 'workspace-id',
isPasswordAuthEnabled: false,
isPasswordAuthBypassEnabled: true,
} as WorkspaceEntity;
const userEntity = {
id: 'user-id',
email: 'email',
passwordHash: 'password-hash',
userWorkspaces: [
{
id: 'user-workspace-id',
workspaceId: workspace.id,
} as any,
],
} as unknown as UserEntity;
jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity);
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockResolvedValueOnce(null);
jest
.spyOn(permissionsService, 'userHasWorkspaceSettingPermission')
.mockResolvedValueOnce(false);
await expect(
service.validateLoginWithPassword(
{
email: 'email',
password: 'password',
captchaToken: 'captcha-token',
},
workspace,
),
).rejects.toThrow(
new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
expect(signInUpServiceMock.validatePassword).not.toHaveBeenCalled();
});
it('challenge - user who have an invitation', async () => {
const user = {
email: 'email',
@@ -64,6 +64,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@@ -81,6 +83,7 @@ export class AuthService {
private readonly authSsoService: AuthSsoService,
private readonly userService: UserService,
private readonly signInUpService: SignInUpService,
private readonly permissionsService: PermissionsService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserEntity)
@@ -135,13 +138,6 @@ export class AuthService {
input: UserCredentialsInput,
targetWorkspace?: WorkspaceEntity,
) {
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
throw new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOne({
where: {
email: input.email,
@@ -156,6 +152,21 @@ export class AuthService {
);
}
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
const canBypass = await this.canUserBypassAuthProvider({
user,
workspace: targetWorkspace,
provider: AuthProviderEnum.Password,
});
if (!canBypass) {
throw new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
if (targetWorkspace) {
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
}
@@ -230,10 +241,74 @@ export class AuthService {
}
if (isDefined(workspace)) {
const isProviderEnabled = workspaceValidator.isAuthEnabled(
authParams.provider,
workspace,
);
if (isProviderEnabled) {
return;
}
const existingUser =
userData.type === 'existingUser' ? userData.existingUser : undefined;
if (
existingUser &&
(await this.canUserBypassAuthProvider({
user: existingUser,
workspace,
provider: authParams.provider,
}))
) {
return;
}
workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace);
}
}
private async canUserBypassAuthProvider({
user,
workspace,
provider,
}: {
user: UserEntity;
workspace: WorkspaceEntity;
provider: AuthProviderEnum;
}): Promise<boolean> {
const bypassEnabled = (() => {
switch (provider) {
case AuthProviderEnum.Password:
return workspace.isPasswordAuthBypassEnabled;
case AuthProviderEnum.Google:
return workspace.isGoogleAuthBypassEnabled;
case AuthProviderEnum.Microsoft:
return workspace.isMicrosoftAuthBypassEnabled;
default:
return false;
}
})();
if (!bypassEnabled) {
return false;
}
const userWorkspace = user.userWorkspaces?.find(
(userWorkspace) => userWorkspace.workspaceId === workspace.id,
);
if (!userWorkspace) {
return false;
}
return await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId: userWorkspace.id,
workspaceId: workspace.id,
setting: PermissionFlagType.SSO_BYPASS,
});
}
async signInUp(
params: SignInUpBaseParams &
ExistingUserOrNewUser &
@@ -775,7 +850,8 @@ export class AuthService {
? await this.countAvailableWorkspacesByEmail(email)
: 0;
const existingUser = await this.userService.findUserByEmail(email);
const existingUser =
await this.userService.findUserByEmailWithWorkspaces(email);
if (
!workspaceId &&