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 &&
@@ -205,6 +205,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
});
}
async findUserByEmailWithWorkspaces(email: string) {
return await this.userRepository.findOne({
where: {
email,
},
relations: { userWorkspaces: true },
});
}
async findUserById(id: string) {
return await this.userRepository.findOne({
where: {
@@ -44,6 +44,18 @@ export class AuthProvidersDTO {
microsoft: boolean;
}
@ObjectType('AuthBypassProviders')
export class AuthBypassProvidersDTO {
@Field(() => Boolean)
google: boolean;
@Field(() => Boolean)
password: boolean;
@Field(() => Boolean)
microsoft: boolean;
}
@ObjectType('PublicWorkspaceDataOutput')
export class PublicWorkspaceDataOutput {
@Field(() => UUIDScalarType)
@@ -52,6 +64,9 @@ export class PublicWorkspaceDataOutput {
@Field(() => AuthProvidersDTO)
authProviders: AuthProvidersDTO;
@Field(() => AuthBypassProvidersDTO, { nullable: true })
authBypassProviders?: AuthBypassProvidersDTO;
@Field(() => String, { nullable: true })
logo: WorkspaceEntity['logo'];
@@ -67,6 +67,21 @@ export class UpdateWorkspaceInput {
@IsOptional()
isPasswordAuthEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isGoogleAuthBypassEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isMicrosoftAuthBypassEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isPasswordAuthBypassEnabled?: boolean;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
@@ -160,6 +160,30 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (payload.isGoogleAuthBypassEnabled && !authProvidersBySystem.google) {
throw new WorkspaceException(
'Google auth is not enabled in the system.',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (
payload.isMicrosoftAuthBypassEnabled &&
!authProvidersBySystem.microsoft
) {
throw new WorkspaceException(
'Microsoft auth is not enabled in the system.',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (
payload.isPasswordAuthBypassEnabled &&
!authProvidersBySystem.password
) {
throw new WorkspaceException(
'Password auth is not enabled in the system.',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
try {
return await this.workspaceRepository.save({
@@ -0,0 +1,25 @@
import { type AuthBypassProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export const getAuthBypassProvidersByWorkspace = ({
workspace,
systemEnabledProviders,
}: {
workspace: Pick<
WorkspaceEntity,
| 'isGoogleAuthBypassEnabled'
| 'isPasswordAuthBypassEnabled'
| 'isMicrosoftAuthBypassEnabled'
>;
systemEnabledProviders: AuthBypassProvidersDTO;
}) => {
return {
google:
workspace.isGoogleAuthBypassEnabled && systemEnabledProviders.google,
password:
workspace.isPasswordAuthBypassEnabled && systemEnabledProviders.password,
microsoft:
workspace.isMicrosoftAuthBypassEnabled &&
systemEnabledProviders.microsoft,
};
};
@@ -230,6 +230,10 @@ export class WorkspaceEntity {
@Column({ default: true })
isGoogleAuthEnabled: boolean;
@Field()
@Column({ default: false })
isGoogleAuthBypassEnabled: boolean;
@Field()
@Column({ default: false })
isTwoFactorAuthenticationEnforced: boolean;
@@ -238,10 +242,18 @@ export class WorkspaceEntity {
@Column({ default: true })
isPasswordAuthEnabled: boolean;
@Field()
@Column({ default: false })
isPasswordAuthBypassEnabled: boolean;
@Field()
@Column({ default: true })
isMicrosoftAuthEnabled: boolean;
@Field()
@Column({ default: false })
isMicrosoftAuthBypassEnabled: boolean;
@Field()
@Column({ default: false })
isCustomDomainEnabled: boolean;
@@ -46,6 +46,7 @@ import {
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { getAuthBypassProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -367,6 +368,10 @@ export class WorkspaceResolver {
workspace,
systemEnabledProviders,
}),
authBypassProviders: getAuthBypassProvidersByWorkspace({
workspace,
systemEnabledProviders,
}),
};
} catch (err) {
workspaceGraphqlApiExceptionHandler(err);