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:
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user