mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-23 12:55:23 +02:00
952 lines
31 KiB
TypeScript
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');
|
|
});
|
|
});
|