mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 17:57:06 +02:00
626 lines
17 KiB
TypeScript
626 lines
17 KiB
TypeScript
import type { LoginRequestDto } from '@n8n/api-types';
|
|
import { Logger } from '@n8n/backend-common';
|
|
import { mockInstance } from '@n8n/backend-test-utils';
|
|
import type { AuthenticatedRequest, User } from '@n8n/db';
|
|
import { UserRepository } from '@n8n/db';
|
|
import { Container } from '@n8n/di';
|
|
import type { Response } from 'express';
|
|
import { mock } from 'jest-mock-extended';
|
|
|
|
import { AuthHandlerRegistry } from '@/auth/auth-handler.registry';
|
|
import type { EmailAuthHandler } from '@/auth/handlers/email.auth-handler';
|
|
import { AuthService } from '@/auth/auth.service';
|
|
import config from '@/config';
|
|
import { EventService } from '@/events/event.service';
|
|
import { LdapService } from '@/modules/ldap.ee/ldap.service.ee';
|
|
import { License } from '@/license';
|
|
import { MfaService } from '@/mfa/mfa.service';
|
|
import { PostHogClient } from '@/posthog';
|
|
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';
|
|
|
|
describe('AuthController', () => {
|
|
mockInstance(Logger);
|
|
mockInstance(EventService);
|
|
mockInstance(AuthService);
|
|
mockInstance(MfaService);
|
|
mockInstance(UserService);
|
|
mockInstance(UserRepository);
|
|
mockInstance(PostHogClient);
|
|
mockInstance(License);
|
|
const ldapService = mockInstance(LdapService);
|
|
const authHandlerRegistry = mockInstance(AuthHandlerRegistry);
|
|
const emailAuthHandler = mock<EmailAuthHandler>();
|
|
const controller = Container.get(AuthController);
|
|
const userService = Container.get(UserService);
|
|
const authService = Container.get(AuthService);
|
|
const eventsService = Container.get(EventService);
|
|
const postHog = Container.get(PostHogClient);
|
|
|
|
describe('login', () => {
|
|
beforeEach(() => {
|
|
jest.resetAllMocks();
|
|
// Setup auth handler registry to return handlers
|
|
authHandlerRegistry.get.mockImplementation((method: string) => {
|
|
if (method === 'ldap') {
|
|
return ldapService;
|
|
}
|
|
if (method === 'email') {
|
|
return emailAuthHandler;
|
|
}
|
|
return undefined;
|
|
});
|
|
});
|
|
|
|
it('should not validate email in "emailOrLdapLoginId" if LDAP is enabled', async () => {
|
|
// Arrange
|
|
|
|
const browserId = '1';
|
|
|
|
const member = mock<User>({
|
|
id: '123',
|
|
role: { slug: 'global:member' },
|
|
mfaEnabled: false,
|
|
});
|
|
|
|
const body = mock<LoginRequestDto>({
|
|
emailOrLdapLoginId: 'non email',
|
|
password: 'password',
|
|
});
|
|
|
|
const req = mock<AuthenticatedRequest>({
|
|
user: member,
|
|
body,
|
|
browserId,
|
|
});
|
|
|
|
const res = mock<Response>();
|
|
|
|
emailAuthHandler.handleLogin.mockResolvedValue(member);
|
|
|
|
ldapService.handleLogin.mockResolvedValue(member);
|
|
|
|
config.set('userManagement.authenticationMethod', 'ldap');
|
|
|
|
// Act
|
|
|
|
await controller.login(req, res, body);
|
|
|
|
// Assert
|
|
|
|
expect(emailAuthHandler.handleLogin).toHaveBeenCalledWith(
|
|
body.emailOrLdapLoginId,
|
|
body.password,
|
|
);
|
|
|
|
expect(ldapService.handleLogin).toHaveBeenCalledWith(body.emailOrLdapLoginId, body.password);
|
|
|
|
expect(authService.issueCookie).toHaveBeenCalledWith(res, member, false, browserId);
|
|
expect(eventsService.emit).toHaveBeenCalledWith('user-logged-in', {
|
|
user: member,
|
|
authenticationMethod: 'ldap',
|
|
});
|
|
|
|
expect(userService.toPublic).toHaveBeenCalledWith(member, {
|
|
mfaAuthenticated: false,
|
|
posthog: postHog,
|
|
withScopes: true,
|
|
});
|
|
});
|
|
|
|
it('should not allow members to login with email if "OIDC" is the authentication method', async () => {
|
|
const member = mock<User>({
|
|
id: '123',
|
|
role: { slug: 'global:member' },
|
|
mfaEnabled: false,
|
|
});
|
|
|
|
const body = mock<LoginRequestDto>({
|
|
emailOrLdapLoginId: 'user@example.com',
|
|
password: 'password',
|
|
});
|
|
|
|
const req = mock<AuthenticatedRequest>({
|
|
user: member,
|
|
body,
|
|
browserId: '1',
|
|
});
|
|
|
|
const res = mock<Response>();
|
|
|
|
emailAuthHandler.handleLogin.mockResolvedValue(member);
|
|
config.set('userManagement.authenticationMethod', 'oidc');
|
|
|
|
// Act
|
|
|
|
await expect(controller.login(req, res, body)).rejects.toThrowError(
|
|
new AuthError('SSO is enabled, please log in with SSO'),
|
|
);
|
|
|
|
// Assert
|
|
|
|
expect(emailAuthHandler.handleLogin).toHaveBeenCalledWith(
|
|
body.emailOrLdapLoginId,
|
|
body.password,
|
|
);
|
|
expect(authService.issueCookie).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow owners to login with email if "OIDC" is the authentication method', async () => {
|
|
config.set('userManagement.authenticationMethod', 'oidc');
|
|
|
|
const member = mock<User>({
|
|
id: '123',
|
|
role: { slug: 'global:owner' },
|
|
mfaEnabled: false,
|
|
});
|
|
|
|
const body = mock<LoginRequestDto>({
|
|
emailOrLdapLoginId: 'user@example.com',
|
|
password: 'password',
|
|
});
|
|
|
|
const req = mock<AuthenticatedRequest>({
|
|
user: member,
|
|
body,
|
|
browserId: '1',
|
|
});
|
|
|
|
const res = mock<Response>();
|
|
|
|
emailAuthHandler.handleLogin.mockResolvedValue(member); // Act
|
|
|
|
await controller.login(req, res, body);
|
|
|
|
// Assert
|
|
|
|
expect(emailAuthHandler.handleLogin).toHaveBeenCalledWith(
|
|
body.emailOrLdapLoginId,
|
|
body.password,
|
|
);
|
|
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,
|
|
authHandlerRegistry,
|
|
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,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
const payload = new ResolveSignupTokenQueryDto({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest.spyOn(userService, 'getInvitationIdsFromPayload').mockResolvedValue({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
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,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
const payload = new ResolveSignupTokenQueryDto({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest.spyOn(userService, 'getInvitationIdsFromPayload').mockResolvedValue({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
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,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
const payload = new ResolveSignupTokenQueryDto({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest.spyOn(userService, 'getInvitationIdsFromPayload').mockResolvedValue({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
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,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
const payload = new ResolveSignupTokenQueryDto({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest.spyOn(userService, 'getInvitationIdsFromPayload').mockResolvedValue({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
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,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
const payload = new ResolveSignupTokenQueryDto({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest.spyOn(userService, 'getInvitationIdsFromPayload').mockResolvedValue({
|
|
inviterId: id,
|
|
inviteeId: id,
|
|
});
|
|
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',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('validates JWT token and returns inviter if token is valid', async () => {
|
|
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
|
const inviterId = uuidv4();
|
|
const inviteeId = uuidv4();
|
|
const token = 'valid-jwt-token';
|
|
|
|
const authController = new AuthController(
|
|
logger,
|
|
authService,
|
|
mfaService,
|
|
userService,
|
|
license,
|
|
userRepository,
|
|
eventService,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
// Use type assertion to bypass zod-class validation for all-optional schemas
|
|
// Validation is handled in the service layer
|
|
const payload = { token } as ResolveSignupTokenQueryDto;
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest.spyOn(userService, 'getInvitationIdsFromPayload').mockResolvedValue({
|
|
inviterId,
|
|
inviteeId,
|
|
});
|
|
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
|
jest.spyOn(userRepository, 'findManyByIds').mockResolvedValue([
|
|
mock<User>({
|
|
id: inviterId,
|
|
email: 'inviter@example.com',
|
|
firstName: 'Inviter first name',
|
|
lastName: 'Inviter last name',
|
|
password: null,
|
|
}),
|
|
mock<User>({
|
|
id: inviteeId,
|
|
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',
|
|
},
|
|
});
|
|
|
|
expect(userService.getInvitationIdsFromPayload).toHaveBeenCalledWith(payload);
|
|
});
|
|
|
|
it('throws BadRequestError if JWT token is invalid', async () => {
|
|
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
|
const token = 'invalid-jwt-token';
|
|
|
|
const authController = new AuthController(
|
|
logger,
|
|
authService,
|
|
mfaService,
|
|
userService,
|
|
license,
|
|
userRepository,
|
|
eventService,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
// Use type assertion to bypass zod-class validation for all-optional schemas
|
|
// Validation is handled in the service layer
|
|
const payload = { token } as ResolveSignupTokenQueryDto;
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest
|
|
.spyOn(userService, 'getInvitationIdsFromPayload')
|
|
.mockRejectedValue(new BadRequestError('Invalid invite URL'));
|
|
|
|
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
|
new BadRequestError('Invalid invite URL'),
|
|
);
|
|
});
|
|
|
|
it('throws BadRequestError if JWT token payload is missing inviterId or inviteeId', async () => {
|
|
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
|
const token = 'valid-jwt-token';
|
|
|
|
const authController = new AuthController(
|
|
logger,
|
|
authService,
|
|
mfaService,
|
|
userService,
|
|
license,
|
|
userRepository,
|
|
eventService,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
// Use type assertion to bypass zod-class validation for all-optional schemas
|
|
// Validation is handled in the service layer
|
|
const payload = { token } as ResolveSignupTokenQueryDto;
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest
|
|
.spyOn(userService, 'getInvitationIdsFromPayload')
|
|
.mockRejectedValue(new BadRequestError('Invalid invite URL'));
|
|
|
|
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
|
new BadRequestError('Invalid invite URL'),
|
|
);
|
|
});
|
|
|
|
it('throws BadRequestError if neither token nor inviterId/inviteeId are provided', async () => {
|
|
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
|
|
|
const authController = new AuthController(
|
|
logger,
|
|
authService,
|
|
mfaService,
|
|
userService,
|
|
license,
|
|
userRepository,
|
|
eventService,
|
|
authHandlerRegistry,
|
|
postHog,
|
|
);
|
|
|
|
// Create empty payload to test validation when neither token nor IDs are provided
|
|
// Use type assertion to bypass zod-class validation for all-optional schemas
|
|
// Validation is handled in the service layer
|
|
const payload = {} as ResolveSignupTokenQueryDto;
|
|
|
|
const req = mock<AuthlessRequest>({
|
|
body: payload,
|
|
});
|
|
const res = mock<Response>();
|
|
|
|
jest
|
|
.spyOn(userService, 'getInvitationIdsFromPayload')
|
|
.mockRejectedValue(new BadRequestError('Invalid invite URL'));
|
|
|
|
await expect(authController.resolveSignupToken(req, res, payload)).rejects.toThrow(
|
|
new BadRequestError('Invalid invite URL'),
|
|
);
|
|
});
|
|
});
|
|
});
|