n8n/packages/cli/test/integration/saml/saml.api.test.ts
Declan Carroll 75ed71c001
fix(core): Add ESLint rule to prevent error instances in toThrow assertions (#29889)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 05:51:05 +00:00

952 lines
31 KiB
TypeScript

// Global mocks in test/setup-mocks.ts replace `node:fs` with jest auto-mocks,
// which breaks express view lookup in the SAML connection-test round-trip.
// Restore the real fs so the ACS handler can render its handlebars template.
jest.unmock('node:fs');
import type { SamlPreferences } from '@n8n/api-types';
import {
createTeamProject,
getProjectRoleForUser,
randomEmail,
randomName,
randomValidPassword,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import {
AuthIdentity,
AuthIdentityRepository,
type User,
UserRepository,
RoleRepository,
RoleMappingRuleRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type express from 'express';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import {
EC_TEST_CERTIFICATE,
EC_TEST_PRIVATE_KEY,
RSA_MISMATCHED_CERTIFICATE,
RSA_TEST_CERTIFICATE,
RSA_TEST_PRIVATE_KEY,
} from '@/modules/sso-saml/__tests__/saml-signing-test-fixtures';
import { TEMPLATES_DIR } from '@/constants';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import { setSamlLoginEnabled } from '@/modules/sso-saml/saml-helpers';
import { SamlService } from '@/modules/sso-saml/saml.service.ee';
import {
getCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers';
import { createHandlebarsEngine } from '@/utils/handlebars.util';
import { sampleConfig } from './sample-metadata';
import { createOwner, createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
let someUser: User;
let owner: User;
let samlUser: User;
let authMemberAgent: SuperAgentTest;
let authOwnerAgent: SuperAgentTest;
let authSamlUserAgent: SuperAgentTest;
async function enableSaml(enable: boolean) {
await setSamlLoginEnabled(enable);
}
async function attachSamlIdentity(user: User, providerId: string) {
await Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, providerId, 'saml'));
}
const testServer = utils.setupTestServer({
endpointGroups: ['me', 'saml'],
enabledFeatures: ['feat:saml'],
});
const memberPassword = randomValidPassword();
const samlUserPassword = randomValidPassword();
beforeAll(async () => {
owner = await createOwner();
someUser = await createUser({ password: memberPassword });
samlUser = await createUser({ password: samlUserPassword });
await attachSamlIdentity(samlUser, `saml-${samlUser.id}`);
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(someUser);
authSamlUserAgent = testServer.authAgentFor(samlUser);
Container.get(GlobalConfig).sso.saml.loginEnabled = true;
});
beforeEach(async () => await enableSaml(false));
describe('Instance owner', () => {
describe('PATCH /me', () => {
test('should succeed with valid inputs', async () => {
await enableSaml(false);
await authOwnerAgent
.patch('/me')
.send({
email: owner.email,
firstName: randomName(),
lastName: randomName(),
})
.expect(200);
});
test('should throw BadRequestError if email is changed when SAML is enabled', async () => {
await enableSaml(true);
await authOwnerAgent
.patch('/me')
.send({
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
})
.expect(400, { code: 400, message: 'SAML user may not change their email' });
});
});
describe('PATCH /me for a user with a SAML auth_identity', () => {
test('should reject profile update while SAML is enabled', async () => {
await enableSaml(true);
await authSamlUserAgent
.patch('/me')
.send({
email: samlUser.email,
firstName: 'NewFirst',
lastName: samlUser.lastName,
})
.expect(400, {
code: 400,
message: 'SAML user may not change their profile information',
});
});
test('should allow profile update once SAML is disabled', async () => {
await enableSaml(false);
const newFirstName = randomName();
const newLastName = randomName();
await authSamlUserAgent
.patch('/me')
.send({
email: samlUser.email,
firstName: newFirstName,
lastName: newLastName,
})
.expect(200);
const refreshed = await Container.get(UserRepository).findOneByOrFail({ id: samlUser.id });
expect(refreshed.firstName).toBe(newFirstName);
expect(refreshed.lastName).toBe(newLastName);
samlUser.firstName = newFirstName;
samlUser.lastName = newLastName;
});
test('should allow email change once SAML is disabled', async () => {
await enableSaml(false);
const newEmail = randomEmail();
await authSamlUserAgent
.patch('/me')
.send({
email: newEmail,
firstName: samlUser.firstName,
lastName: samlUser.lastName,
currentPassword: samlUserPassword,
})
.expect(200);
const refreshed = await Container.get(UserRepository).findOneByOrFail({ id: samlUser.id });
expect(refreshed.email).toBe(newEmail);
samlUser.email = newEmail;
});
});
describe('PATCH /password', () => {
test('should throw BadRequestError if password is changed when SAML is enabled', async () => {
await enableSaml(true);
await authMemberAgent
.patch('/me/password')
.send({
currentPassword: memberPassword,
newPassword: randomValidPassword(),
})
.expect(400, {
code: 400,
message: 'With SAML enabled, users need to use their SAML provider to change passwords',
});
});
});
describe('POST /sso/saml/config', () => {
test('should post saml config', async () => {
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
loginEnabled: true,
})
.expect(200);
expect(getCurrentAuthenticationMethod()).toBe('saml');
});
test('should return 400 on invalid config', async () => {
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
loginBinding: 'invalid',
})
.expect(400);
expect(getCurrentAuthenticationMethod()).toBe('email');
});
});
describe('POST /sso/saml/config/toggle', () => {
test('should toggle saml as default authentication method', async () => {
await enableSaml(true);
expect(getCurrentAuthenticationMethod()).toBe('saml');
await authOwnerAgent
.post('/sso/saml/config/toggle')
.send({
loginEnabled: false,
})
.expect(200);
expect(getCurrentAuthenticationMethod()).toBe('email');
await authOwnerAgent
.post('/sso/saml/config/toggle')
.send({
loginEnabled: true,
})
.expect(200);
expect(getCurrentAuthenticationMethod()).toBe('saml');
});
});
describe('POST /sso/saml/config/toggle', () => {
test('should fail enable saml if default authentication is not email', async () => {
await enableSaml(true);
await authOwnerAgent
.post('/sso/saml/config/toggle')
.send({
loginEnabled: false,
})
.expect(200);
expect(getCurrentAuthenticationMethod()).toBe('email');
await setCurrentAuthenticationMethod('ldap');
expect(getCurrentAuthenticationMethod()).toBe('ldap');
await authOwnerAgent
.post('/sso/saml/config/toggle')
.send({
loginEnabled: true,
})
.expect(500);
expect(getCurrentAuthenticationMethod()).toBe('ldap');
await setCurrentAuthenticationMethod('saml');
});
});
});
describe('Check endpoint permissions', () => {
beforeEach(async () => {
await enableSaml(true);
});
describe('Owner', () => {
test('should be able to access GET /sso/saml/metadata', async () => {
await authOwnerAgent.get('/sso/saml/metadata').expect(200);
});
test('should be able to access GET /sso/saml/config', async () => {
await authOwnerAgent.get('/sso/saml/config').expect(200);
});
test('should be able to access POST /sso/saml/config', async () => {
await authOwnerAgent.post('/sso/saml/config').expect(200);
});
test('should be able to access POST /sso/saml/config/toggle', async () => {
await authOwnerAgent.post('/sso/saml/config/toggle').expect(400);
});
test('should be able to access GET /sso/saml/acs', async () => {
// Note that 401 here is coming from the missing SAML object,
// not from not being able to access the endpoint, so this is expected!
const response = await authOwnerAgent.get('/sso/saml/acs').expect(401);
expect(response.text).toContain('SAML Authentication failed');
});
test('should be able to access POST /sso/saml/acs', async () => {
// Note that 401 here is coming from the missing SAML object,
// not from not being able to access the endpoint, so this is expected!
const response = await authOwnerAgent.post('/sso/saml/acs').expect(401);
expect(response.text).toContain('SAML Authentication failed');
});
test('should be able to access GET /sso/saml/initsso', async () => {
await authOwnerAgent.get('/sso/saml/initsso').expect(200);
});
test('should be able to access POST /sso/saml/config/test', async () => {
await authOwnerAgent
.post('/sso/saml/config/test')
.send({ metadata: sampleConfig.metadata })
.expect(200);
});
});
describe('Authenticated Member', () => {
test('should be able to access GET /sso/saml/metadata', async () => {
await authMemberAgent.get('/sso/saml/metadata').expect(200);
});
test('should be able to access GET /sso/saml/config', async () => {
await authMemberAgent.get('/sso/saml/config').expect(200);
});
test('should NOT be able to access POST /sso/saml/config', async () => {
await authMemberAgent.post('/sso/saml/config').expect(403);
});
test('should NOT be able to access POST /sso/saml/config/toggle', async () => {
await authMemberAgent.post('/sso/saml/config/toggle').expect(403);
});
test('should be able to access GET /sso/saml/acs', async () => {
// Note that 401 here is coming from the missing SAML object,
// not from not being able to access the endpoint, so this is expected!
const response = await authMemberAgent.get('/sso/saml/acs').expect(401);
expect(response.text).toContain('SAML Authentication failed');
});
test('should be able to access POST /sso/saml/acs', async () => {
// Note that 401 here is coming from the missing SAML object,
// not from not being able to access the endpoint, so this is expected!
const response = await authMemberAgent.post('/sso/saml/acs').expect(401);
expect(response.text).toContain('SAML Authentication failed');
});
test('should be able to access GET /sso/saml/initsso', async () => {
await authMemberAgent.get('/sso/saml/initsso').expect(200);
});
test('should NOT be able to access POST /sso/saml/config/test', async () => {
await authMemberAgent.post('/sso/saml/config/test').expect(403);
});
});
describe('Non-Authenticated User', () => {
test('should be able to access /sso/saml/metadata', async () => {
await testServer.authlessAgent.get('/sso/saml/metadata').expect(200);
});
test('should NOT be able to access GET /sso/saml/config', async () => {
await testServer.authlessAgent.get('/sso/saml/config').expect(401);
});
test('should NOT be able to access POST /sso/saml/config', async () => {
await testServer.authlessAgent.post('/sso/saml/config').expect(401);
});
test('should NOT be able to access POST /sso/saml/config/toggle', async () => {
await testServer.authlessAgent.post('/sso/saml/config/toggle').expect(401);
});
test('should be able to access GET /sso/saml/acs', async () => {
// Note that 401 here is coming from the missing SAML object,
// not from not being able to access the endpoint, so this is expected!
const response = await testServer.authlessAgent.get('/sso/saml/acs').expect(401);
expect(response.text).toContain('SAML Authentication failed');
});
test('should be able to access POST /sso/saml/acs', async () => {
// Note that 401 here is coming from the missing SAML object,
// not from not being able to access the endpoint, so this is expected!
const response = await testServer.authlessAgent.post('/sso/saml/acs').expect(401);
expect(response.text).toContain('SAML Authentication failed');
});
test('should be able to access GET /sso/saml/initsso', async () => {
await testServer.authlessAgent.get('/sso/saml/initsso').expect(200);
});
test('should NOT be able to access POST /sso/saml/config/test', async () => {
await testServer.authlessAgent.post('/sso/saml/config/test').expect(401);
});
});
});
describe('POST /sso/saml/config/test round-trip', () => {
beforeAll(() => {
// ACS renders handlebars templates; configure the engine on the test app.
testServer.app.engine('handlebars', createHandlebarsEngine());
testServer.app.set('view engine', 'handlebars');
testServer.app.set('views', TEMPLATES_DIR);
});
beforeEach(async () => {
await enableSaml(false);
await Container.get(SamlService).reset();
});
test('embeds a test token in the RelayState when metadata is provided without saving', async () => {
const response = await authOwnerAgent
.post('/sso/saml/config/test')
.send({ metadata: sampleConfig.metadata, loginBinding: 'redirect' })
.expect(200);
// Body is the IdP redirect URL; its RelayState query param must point at the
// test return URL and include our opaque test token.
const redirectUrl = new URL(response.body.data as string);
const relayState = redirectUrl.searchParams.get('RelayState');
expect(relayState).toBeTruthy();
const relayStateUrl = new URL(relayState!);
expect(relayStateUrl.pathname).toBe('/config/test/return');
expect(relayStateUrl.searchParams.get('t')).toMatch(/^[0-9a-f]+$/);
});
test('ACS callback with the test token does not fail with "No IdP metadata configured"', async () => {
// Prime the test config without persisting SAML preferences.
const initResponse = await authOwnerAgent
.post('/sso/saml/config/test')
.send({ metadata: sampleConfig.metadata, loginBinding: 'redirect' })
.expect(200);
const relayState = new URL(initResponse.body.data as string).searchParams.get('RelayState')!;
const acsResponse = await testServer.authlessAgent
.post('/sso/saml/acs')
.type('form')
.send({ RelayState: relayState, SAMLResponse: 'invalid' })
.expect(200);
// The rendered failure template proves we handled this as a test-connection
// flow (not a login auth error). The distinctive pre-fix error message
// must not appear — cached metadata should have been consumed.
expect(acsResponse.text).toContain('SAML Connection Test failed');
expect(acsResponse.text).not.toContain('No IdP metadata configured');
});
test('ACS callback consumes the test token so a later lookup returns undefined', async () => {
const initResponse = await authOwnerAgent
.post('/sso/saml/config/test')
.send({ metadata: sampleConfig.metadata, loginBinding: 'redirect' })
.expect(200);
const relayState = new URL(initResponse.body.data as string).searchParams.get('RelayState')!;
const testId = new URL(relayState).searchParams.get('t')!;
await testServer.authlessAgent
.post('/sso/saml/acs')
.type('form')
.send({ RelayState: relayState, SAMLResponse: 'invalid' })
.expect(200);
// After the ACS callback, the cached metadata must be gone — confirming
// the token is single-use.
const consumed = await Container.get(SamlService).consumePendingTestConfig(testId);
expect(consumed).toBeUndefined();
});
});
describe('Signing key configuration via API', () => {
const originalEnv = process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS;
afterEach(() => {
if (originalEnv !== undefined) {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = originalEnv;
} else {
delete process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS;
}
});
describe('POST /sso/saml/config with signing keys', () => {
test('should reject signing keys when feature flag is disabled', async () => {
delete process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS;
const response = await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(400);
expect(response.body.message).toContain('SAML request signing is not enabled');
});
test('should accept valid RSA signing key pair', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(200);
});
test('should accept valid EC signing key pair', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: EC_TEST_PRIVATE_KEY,
signingCertificate: EC_TEST_CERTIFICATE,
})
.expect(200);
});
test('should reject mismatched key and certificate', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
const response = await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
authnRequestsSigned: true,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_MISMATCHED_CERTIFICATE,
})
.expect(400);
expect(response.body.message).toContain('do not match');
});
test('should reject invalid PEM private key format', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
const response = await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: 'not-a-valid-pem-key',
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(400);
expect(response.body.message).toContain('Invalid signing private key format');
});
test('should reject invalid PEM certificate format', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
const response = await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: 'not-a-valid-pem-cert',
})
.expect(400);
expect(response.body.message).toContain('Invalid signing certificate format');
});
test('should require both key and cert when authnRequestsSigned is true', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
// Clear any signing keys stored from prior tests
const samlService = Container.get(SamlService);
type PrivatePrefs = { _samlPreferences: SamlPreferences };
(samlService as unknown as PrivatePrefs)._samlPreferences.signingPrivateKey = undefined;
(samlService as unknown as PrivatePrefs)._samlPreferences.signingCertificate = undefined;
const response = await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
authnRequestsSigned: true,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
})
.expect(400);
expect(response.body.message).toContain(
'Both signingPrivateKey and signingCertificate are required',
);
});
});
describe('GET /sso/saml/config after setting signing keys', () => {
test('should return redacted private key and plaintext certificate after POST', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
// POST the config with signing keys
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(200);
// GET the config back (response is wrapped in { data: ... })
const response = await authOwnerAgent.get('/sso/saml/config').expect(200);
const config = response.body.data;
// Private key should be redacted with the blanking value, never plaintext
expect(config.signingPrivateKey).toBe(CREDENTIAL_BLANKING_VALUE);
// Certificate should be returned in plaintext (it's public data)
expect(config.signingCertificate).toBe(RSA_TEST_CERTIFICATE);
});
test('should return redacted EC private key and plaintext certificate after POST', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: EC_TEST_PRIVATE_KEY,
signingCertificate: EC_TEST_CERTIFICATE,
})
.expect(200);
const response = await authOwnerAgent.get('/sso/saml/config').expect(200);
const config = response.body.data;
expect(config.signingPrivateKey).toBe(CREDENTIAL_BLANKING_VALUE);
expect(config.signingCertificate).toBe(EC_TEST_CERTIFICATE);
});
test('should not return signing fields when none were set', async () => {
// Clear any previously stored signing keys from prior tests
const samlService = Container.get(SamlService);
type PrivatePrefs = { _samlPreferences: SamlPreferences };
(samlService as unknown as PrivatePrefs)._samlPreferences.signingPrivateKey = undefined;
(samlService as unknown as PrivatePrefs)._samlPreferences.signingCertificate = undefined;
// POST config without signing keys
await authOwnerAgent.post('/sso/saml/config').send(sampleConfig).expect(200);
const response = await authOwnerAgent.get('/sso/saml/config').expect(200);
const config = response.body.data;
// Fields should not be present (JSON.stringify drops undefined values)
expect(config.signingPrivateKey).toBeUndefined();
expect(config.signingCertificate).toBeUndefined();
});
});
describe('POST + GET round-trip preserves decryptability', () => {
test('should allow service to decrypt RSA key after API round-trip', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(200);
const samlService = Container.get(SamlService);
// @ts-expect-error -- accessing private method for testing
const decryptedKey = await samlService.getDecryptedSigningPrivateKey();
expect(decryptedKey).toBe(RSA_TEST_PRIVATE_KEY);
});
test('should allow service to decrypt EC key after API round-trip', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: EC_TEST_PRIVATE_KEY,
signingCertificate: EC_TEST_CERTIFICATE,
})
.expect(200);
const samlService = Container.get(SamlService);
// @ts-expect-error -- accessing private method for testing
const decryptedKey = await samlService.getDecryptedSigningPrivateKey();
expect(decryptedKey).toBe(EC_TEST_PRIVATE_KEY);
});
test('should clear signing key when empty string is sent via POST', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
// POST with real key
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(200);
// Verify key is stored
const samlService = Container.get(SamlService);
// @ts-expect-error -- accessing private method for testing
expect(await samlService.getDecryptedSigningPrivateKey()).toBe(RSA_TEST_PRIVATE_KEY);
// Clear both fields
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: '',
signingCertificate: '',
})
.expect(200);
// Key should be cleared
// @ts-expect-error -- accessing private method for testing
expect(await samlService.getDecryptedSigningPrivateKey()).toBeUndefined();
expect(samlService.samlPreferences.signingCertificate).toBeUndefined();
// GET should not return signing fields
const response = await authOwnerAgent.get('/sso/saml/config').expect(200);
expect(response.body.data.signingPrivateKey).toBeUndefined();
expect(response.body.data.signingCertificate).toBeUndefined();
});
test('should preserve existing key when POST sends back blanking value', async () => {
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
// POST with real key
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(200);
// Simulate UI round-trip: GET returns blanking value, UI sends it back in POST
await authOwnerAgent
.post('/sso/saml/config')
.send({
...sampleConfig,
signingPrivateKey: CREDENTIAL_BLANKING_VALUE,
signingCertificate: RSA_TEST_CERTIFICATE,
})
.expect(200);
// Key should still be decryptable to original value
const samlService = Container.get(SamlService);
// @ts-expect-error -- accessing private method for testing
const decryptedKey = await samlService.getDecryptedSigningPrivateKey();
expect(decryptedKey).toBe(RSA_TEST_PRIVATE_KEY);
});
});
});
describe('SAML email validation', () => {
let samlService: SamlService;
beforeAll(async () => {
samlService = Container.get(SamlService);
});
describe('handleSamlLogin', () => {
test('should throw BadRequestError for invalid email format', async () => {
// Mock getAttributesFromLoginResponse to return invalid email
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
mapped: {
email: 'invalid-email-format',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
n8nInstanceRole: 'n8n_instance_role',
},
raw: {},
});
const mockRequest = {} as express.Request;
const promise = samlService.handleSamlLogin(mockRequest, 'post');
await expect(promise).rejects.toThrow(BadRequestError);
await expect(promise).rejects.toThrow('Invalid email format');
});
test.each([['not-an-email'], ['@missinglocal.com'], ['missing@.com'], ['spaces in@email.com']])(
'should throw BadRequestError for invalid email <%s>',
async (invalidEmail) => {
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
mapped: {
email: invalidEmail,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
n8nInstanceRole: 'n8n_instance_role',
},
raw: {},
});
const mockRequest = {} as express.Request;
const promise = samlService.handleSamlLogin(mockRequest, 'post');
await expect(promise).rejects.toThrow(BadRequestError);
await expect(promise).rejects.toThrow('Invalid email format');
},
);
test.each([
['user@example.com'],
['test.email@domain.org'],
['user+tag@example.com'],
['user123@test-domain.com'],
])('should handle valid email <%s> successfully', async (validEmail) => {
const mockRequest = {} as express.Request;
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
mapped: {
email: validEmail,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
n8nInstanceRole: 'n8n_instance_role',
},
raw: {},
});
// Should not throw an error for valid emails
const result = await samlService.handleSamlLogin(mockRequest, 'post');
expect(result).toBeDefined();
expect(result.attributes.email).toBe(validEmail);
});
test('should convert email to lowercase before validation', async () => {
const upperCaseEmail = 'USER@EXAMPLE.COM';
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
mapped: {
email: upperCaseEmail,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
n8nInstanceRole: 'n8n_instance_role',
},
raw: {},
});
const mockRequest = {} as express.Request;
// Should not throw an error as the email is valid when converted to lowercase
const result = await samlService.handleSamlLogin(mockRequest, 'post');
expect(result).toBeDefined();
expect(result.attributes.email).toBe(upperCaseEmail); // Original email should be preserved in attributes
});
});
});
describe('SAML SSO provisioning', () => {
let samlService: SamlService;
let roleMappingRuleRepository: RoleMappingRuleRepository;
let roleRepository: RoleRepository;
let userRepository: UserRepository;
let savedProvisioningConfig: unknown;
beforeAll(async () => {
samlService = Container.get(SamlService);
roleMappingRuleRepository = Container.get(RoleMappingRuleRepository);
roleRepository = Container.get(RoleRepository);
userRepository = Container.get(UserRepository);
await Container.get(ProvisioningService).init();
});
beforeEach(() => {
const provisioningService = Container.get(ProvisioningService);
// @ts-expect-error - provisioningConfig is private
savedProvisioningConfig = { ...provisioningService.provisioningConfig };
// @ts-expect-error - provisioningConfig is private
provisioningService.provisioningConfig.scopesUseExpressionMapping = true;
});
afterEach(async () => {
const provisioningService = Container.get(ProvisioningService);
// @ts-expect-error - provisioningConfig is private
provisioningService.provisioningConfig = { ...savedProvisioningConfig };
await roleMappingRuleRepository.delete({});
});
it('should provision instance role via expression mapping', async () => {
const adminRole = await roleRepository.findOneOrFail({ where: { slug: 'global:admin' } });
await roleMappingRuleRepository.save(
roleMappingRuleRepository.create({
expression: "{{ $claims.department === 'it' }}",
role: adminRole,
type: 'instance',
order: 0,
}),
);
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
mapped: {
email: 'saml-expr-instance@example.com',
firstName: 'SAML',
lastName: 'User',
userPrincipalName: 'saml-expr-instance',
},
raw: { department: 'it', email: 'saml-expr-instance@example.com' },
});
const mockRequest = {} as express.Request;
const result = await samlService.handleSamlLogin(mockRequest, 'post');
expect(result).toBeDefined();
const userFromDB = await userRepository.findOne({
where: { email: 'saml-expr-instance@example.com' },
relations: ['role'],
});
expect(userFromDB!.role.slug).toEqual('global:admin');
});
it('should provision project role via expression mapping', async () => {
const project = await createTeamProject('saml-expr-project-role-test');
const editorRole = await roleRepository.findOneOrFail({ where: { slug: 'project:editor' } });
const rule = roleMappingRuleRepository.create({
expression: "{{ $claims.groups !== undefined && $claims.groups.includes('n8n-editors') }}",
role: editorRole,
type: 'project',
order: 0,
});
rule.projects = [project];
await roleMappingRuleRepository.save(rule);
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
mapped: {
email: 'saml-expr-project@example.com',
firstName: 'SAML',
lastName: 'User',
userPrincipalName: 'saml-expr-project',
},
raw: {
email: 'saml-expr-project@example.com',
groups: ['n8n-editors', 'devops'],
},
});
const mockRequest = {} as express.Request;
const result = await samlService.handleSamlLogin(mockRequest, 'post');
expect(result.authenticatedUser).toBeDefined();
const projectRole = await getProjectRoleForUser(project.id, result.authenticatedUser!.id);
expect(projectRole).toEqual('project:editor');
});
});