diff --git a/packages/cli/src/controllers/__tests__/auth.controller.test.ts b/packages/cli/src/controllers/__tests__/auth.controller.test.ts index 1c739489bb8..43764395892 100644 --- a/packages/cli/src/controllers/__tests__/auth.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/auth.controller.test.ts @@ -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({ + body: payload, + }); + const res = mock(); + + 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({ + body: payload, + }); + const res = mock(); + + 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({ + body: payload, + }); + const res = mock(); + + 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({ + body: payload, + }); + const res = mock(); + + jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true); + jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([ + mock({ + id, + password: 'Password123!', + }), + mock({ + 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({ + body: payload, + }); + const res = mock(); + + jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true); + jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([ + mock({ + id, + email: undefined, + password: null, + }), + mock({ + 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({ + body: payload, + }); + const res = mock(); + + jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true); + jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([ + mock({ + id, + email: 'inviter@example.com', + firstName: 'Inviter first name', + lastName: 'Inviter last name', + password: null, + }), + mock({ + 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', + }, + }); + }); + }); }); diff --git a/packages/cli/src/controllers/__tests__/invitation.controller.test.ts b/packages/cli/src/controllers/__tests__/invitation.controller.test.ts index 677d8d03053..638baf6fa0b 100644 --- a/packages/cli/src/controllers/__tests__/invitation.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/invitation.controller.test.ts @@ -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>({ + body: payload, + params: { id }, + }); + const res = mock(); + + 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>({ + body: payload, + params: { id }, + }); + const res = mock(); + + 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({ + id: '123', + email: 'valid@email.com', + password: 'Password123!', + role: GLOBAL_MEMBER_ROLE, + }); + const inviter = mock({ + 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>({ + body: payload, + params: { id }, + }); + + const res = mock(); + + 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({ + id: '124', + email: 'valid@email.com', + role: GLOBAL_OWNER_ROLE, + }); + const invitee = mock({ + 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>({ + body: payload, + params: { id }, + }); + const res = mock(); + + expect(await invitationController.acceptInvitation(req, res, payload, '123')).toEqual( + invitee as unknown as PublicUser, + ); + }); + }); }); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 617614c3d01..c887361eec0 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -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(); diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index f5bf46e783e..2be98a7f2b8 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -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({