feat: Block invite acceptance on SSO systems (#21830)

This commit is contained in:
Stephen Wright 2025-11-13 18:33:39 +00:00 committed by GitHub
parent 5b2d15e78d
commit f73eba7c86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 454 additions and 2 deletions

View File

@ -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',
},
});
});
});
});

View File

@ -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,
);
});
});
});

View File

@ -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();

View File

@ -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({