mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 07:17:04 +02:00
feat: Block invite acceptance on SSO systems (#21830)
This commit is contained in:
parent
5b2d15e78d
commit
f73eba7c86
|
|
@ -19,6 +19,13 @@ import { UserService } from '@/services/user.service';
|
|||
|
||||
import { AuthController } from '../auth.controller';
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { AuthlessRequest } from '@/requests';
|
||||
import * as ssoHelpers from '@/sso.ee/sso-helpers';
|
||||
import { ResolveSignupTokenQueryDto } from '@n8n/api-types';
|
||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
|
||||
jest.mock('@/auth');
|
||||
|
||||
|
|
@ -177,4 +184,249 @@ describe('AuthController', () => {
|
|||
expect(authService.issueCookie).toHaveBeenCalledWith(res, member, false, '1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSignupToken', () => {
|
||||
const logger: Logger = mockInstance(Logger);
|
||||
const mfaService: MfaService = mockInstance(MfaService);
|
||||
const authService: AuthService = mockInstance(AuthService);
|
||||
const userService: UserService = mockInstance(UserService);
|
||||
const license: License = mockInstance(License);
|
||||
const userRepository: UserRepository = mockInstance(UserRepository);
|
||||
const postHog: PostHogClient = mockInstance(PostHogClient);
|
||||
const eventService: EventService = mockInstance(EventService);
|
||||
|
||||
it('throws a BadRequestError if SSO is enabled', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true);
|
||||
const id = uuidv4();
|
||||
|
||||
const authController = new AuthController(
|
||||
logger,
|
||||
authService,
|
||||
mfaService,
|
||||
userService,
|
||||
license,
|
||||
userRepository,
|
||||
eventService,
|
||||
postHog,
|
||||
);
|
||||
|
||||
const payload = new ResolveSignupTokenQueryDto({
|
||||
inviterId: id,
|
||||
inviteeId: id,
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest>({
|
||||
body: payload,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
||||
new BadRequestError(
|
||||
'Invite links are not supported on this system, please use single sign on instead.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a ForbiddenError if the users quota is reached', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
|
||||
const authController = new AuthController(
|
||||
logger,
|
||||
authService,
|
||||
mfaService,
|
||||
userService,
|
||||
license,
|
||||
userRepository,
|
||||
eventService,
|
||||
postHog,
|
||||
);
|
||||
|
||||
const payload = new ResolveSignupTokenQueryDto({
|
||||
inviterId: id,
|
||||
inviteeId: id,
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest>({
|
||||
body: payload,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(false);
|
||||
|
||||
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
||||
new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a BadRequestError if the users are not found', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
|
||||
const authController = new AuthController(
|
||||
logger,
|
||||
authService,
|
||||
mfaService,
|
||||
userService,
|
||||
license,
|
||||
userRepository,
|
||||
eventService,
|
||||
postHog,
|
||||
);
|
||||
|
||||
const payload = new ResolveSignupTokenQueryDto({
|
||||
inviterId: id,
|
||||
inviteeId: id,
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest>({
|
||||
body: payload,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
||||
jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([]);
|
||||
|
||||
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
||||
new BadRequestError('Invalid invite URL'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a BadRequestError if the invitee already has a password', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
|
||||
const authController = new AuthController(
|
||||
logger,
|
||||
authService,
|
||||
mfaService,
|
||||
userService,
|
||||
license,
|
||||
userRepository,
|
||||
eventService,
|
||||
postHog,
|
||||
);
|
||||
|
||||
const payload = new ResolveSignupTokenQueryDto({
|
||||
inviterId: id,
|
||||
inviteeId: id,
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest>({
|
||||
body: payload,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
||||
jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([
|
||||
mock<User>({
|
||||
id,
|
||||
password: 'Password123!',
|
||||
}),
|
||||
mock<User>({
|
||||
id,
|
||||
password: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
||||
new BadRequestError('The invitation was likely either deleted or already claimed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a BadRequestError if the inviter does not exist or is not set up', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
|
||||
const authController = new AuthController(
|
||||
logger,
|
||||
authService,
|
||||
mfaService,
|
||||
userService,
|
||||
license,
|
||||
userRepository,
|
||||
eventService,
|
||||
postHog,
|
||||
);
|
||||
|
||||
const payload = new ResolveSignupTokenQueryDto({
|
||||
inviterId: id,
|
||||
inviteeId: id,
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest>({
|
||||
body: payload,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
||||
jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([
|
||||
mock<User>({
|
||||
id,
|
||||
email: undefined,
|
||||
password: null,
|
||||
}),
|
||||
mock<User>({
|
||||
id,
|
||||
email: undefined,
|
||||
password: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
||||
new BadRequestError('Invalid request'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the inviter if the invitation is valid', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
|
||||
const authController = new AuthController(
|
||||
logger,
|
||||
authService,
|
||||
mfaService,
|
||||
userService,
|
||||
license,
|
||||
userRepository,
|
||||
eventService,
|
||||
postHog,
|
||||
);
|
||||
|
||||
const payload = new ResolveSignupTokenQueryDto({
|
||||
inviterId: id,
|
||||
inviteeId: id,
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest>({
|
||||
body: payload,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
||||
jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([
|
||||
mock<User>({
|
||||
id,
|
||||
email: 'inviter@example.com',
|
||||
firstName: 'Inviter first name',
|
||||
lastName: 'Inviter last name',
|
||||
password: null,
|
||||
}),
|
||||
mock<User>({
|
||||
id,
|
||||
email: 'invitee@example.com',
|
||||
firstName: 'Invitee first name',
|
||||
lastName: 'Invitee last name',
|
||||
password: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(authController.resolveSignupToken(req, res, payload)).resolves.toEqual({
|
||||
inviter: {
|
||||
firstName: 'Inviter first name',
|
||||
lastName: 'Inviter last name',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { AuthService } from '@/auth/auth.service';
|
|||
import { UserService } from '@/services/user.service';
|
||||
import { License } from '@/license';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { User, PublicUser } from '@n8n/db';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import * as ssoHelpers from '@/sso.ee/sso-helpers';
|
||||
import { InvitationController } from '../invitation.controller';
|
||||
import { InviteUsersRequestDto } from '@n8n/api-types';
|
||||
import { AcceptInvitationRequestDto, InviteUsersRequestDto } from '@n8n/api-types';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_ADMIN_ROLE } from '@n8n/db';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
|
|
@ -20,6 +20,8 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import config from '@/config';
|
||||
import type { AuthlessRequest } from '@/requests';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
describe('InvitationController', () => {
|
||||
const logger: Logger = mockInstance(Logger);
|
||||
|
|
@ -249,4 +251,183 @@ describe('InvitationController', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptInvitation', () => {
|
||||
it('throws a BadRequestError if SSO is enabled', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true);
|
||||
const id = uuidv4();
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'Password123!',
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest<{ id: string }>>({
|
||||
body: payload,
|
||||
params: { id },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
await expect(invitationController.acceptInvitation(req, res, payload, '123')).rejects.toThrow(
|
||||
new BadRequestError(
|
||||
'Invite links are not supported on this system, please use single sign on instead.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a BadRequestError if the inviter ID and invitee ID are not found in the database', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'Password123!',
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest<{ id: string }>>({
|
||||
body: payload,
|
||||
params: { id },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
jest.spyOn(userRepository, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(invitationController.acceptInvitation(req, res, payload, '123')).rejects.toThrow(
|
||||
new BadRequestError('Invalid payload or URL'),
|
||||
);
|
||||
|
||||
expect(userRepository.find).toHaveBeenCalledWith({
|
||||
where: [{ id }, { id: '123' }],
|
||||
relations: ['role'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a BadRequestError if the invitee already has a password', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const invitee = mock<User>({
|
||||
id: '123',
|
||||
email: 'valid@email.com',
|
||||
password: 'Password123!',
|
||||
role: GLOBAL_MEMBER_ROLE,
|
||||
});
|
||||
const inviter = mock<User>({
|
||||
id: '124',
|
||||
email: 'valid@email.com',
|
||||
role: GLOBAL_OWNER_ROLE,
|
||||
});
|
||||
jest.spyOn(userRepository, 'find').mockResolvedValue([inviter, invitee]);
|
||||
const id = uuidv4();
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'Password123!',
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest<{ id: string }>>({
|
||||
body: payload,
|
||||
params: { id },
|
||||
});
|
||||
|
||||
const res = mock<Response>();
|
||||
|
||||
await expect(invitationController.acceptInvitation(req, res, payload, '123')).rejects.toThrow(
|
||||
new BadRequestError('This invite has been accepted already'),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the invitation successfully', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
const id = uuidv4();
|
||||
const inviter = mock<User>({
|
||||
id: '124',
|
||||
email: 'valid@email.com',
|
||||
role: GLOBAL_OWNER_ROLE,
|
||||
});
|
||||
const invitee = mock<User>({
|
||||
id: '123',
|
||||
email: 'valid@email.com',
|
||||
password: null,
|
||||
role: GLOBAL_MEMBER_ROLE,
|
||||
});
|
||||
|
||||
jest.spyOn(userRepository, 'find').mockResolvedValue([inviter, invitee]);
|
||||
jest.spyOn(passwordUtility, 'hash').mockResolvedValue('Password123!');
|
||||
jest.spyOn(userRepository, 'save').mockResolvedValue(invitee);
|
||||
jest.spyOn(authService, 'issueCookie').mockResolvedValue(invitee as never);
|
||||
jest.spyOn(eventService, 'emit').mockResolvedValue(invitee as never);
|
||||
jest.spyOn(userService, 'toPublic').mockResolvedValue(invitee as unknown as PublicUser);
|
||||
jest.spyOn(externalHooks, 'run').mockResolvedValue(invitee as never);
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'Password123!',
|
||||
});
|
||||
|
||||
const req = mock<AuthlessRequest<{ id: string }>>({
|
||||
body: payload,
|
||||
params: { id },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
expect(await invitationController.acceptInvitation(req, res, payload, '123')).toEqual(
|
||||
invitee as unknown as PublicUser,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
isLdapCurrentAuthenticationMethod,
|
||||
isOidcCurrentAuthenticationMethod,
|
||||
isSamlCurrentAuthenticationMethod,
|
||||
isSsoCurrentAuthenticationMethod,
|
||||
} from '@/sso.ee/sso-helpers';
|
||||
|
||||
@RestController()
|
||||
|
|
@ -139,6 +140,15 @@ export class AuthController {
|
|||
_res: Response,
|
||||
@Query payload: ResolveSignupTokenQueryDto,
|
||||
) {
|
||||
if (isSsoCurrentAuthenticationMethod()) {
|
||||
this.logger.debug(
|
||||
'Invite links are not supported on this system, please use single sign on instead.',
|
||||
);
|
||||
throw new BadRequestError(
|
||||
'Invite links are not supported on this system, please use single sign on instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const { inviterId, inviteeId } = payload;
|
||||
const isWithinUsersLimit = this.license.isWithinUsersLimit();
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@ export class InvitationController {
|
|||
@Body payload: AcceptInvitationRequestDto,
|
||||
@Param('id') inviteeId: string,
|
||||
) {
|
||||
if (isSsoCurrentAuthenticationMethod()) {
|
||||
this.logger.debug(
|
||||
'Invite links are not supported on this system, please use single sign on instead.',
|
||||
);
|
||||
throw new BadRequestError(
|
||||
'Invite links are not supported on this system, please use single sign on instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const { inviterId, firstName, lastName, password } = payload;
|
||||
|
||||
const users = await this.userRepository.find({
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user