refactor(core): Extract SAML requests payloads into DTOs (#12435)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-01-03 11:05:30 +01:00 committed by GitHub
parent 2241eef8cf
commit 552cff1860
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 535 additions and 354 deletions

View file

@ -21,6 +21,10 @@ export { ForgotPasswordRequestDto } from './password-reset/forgot-password-reque
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { SamlAcsDto } from './saml/saml-acs.dto';
export { SamlPreferences } from './saml/saml-preferences.dto';
export { SamlToggleDto } from './saml/saml-toggle.dto';
export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
export { RoleChangeRequestDto } from './user/role-change-request.dto';
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';

View file

@ -0,0 +1,155 @@
import { SamlPreferences } from '../saml-preferences.dto';
describe('SamlPreferences', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid minimal configuration',
request: {
mapping: {
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
},
metadata: '<xml>metadata</xml>',
metadataUrl: 'https://example.com/metadata',
loginEnabled: true,
loginLabel: 'Login with SAML',
},
},
{
name: 'valid full configuration',
request: {
mapping: {
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
},
metadata: '<xml>metadata</xml>',
metadataUrl: 'https://example.com/metadata',
ignoreSSL: true,
loginBinding: 'post',
loginEnabled: true,
loginLabel: 'Login with SAML',
authnRequestsSigned: true,
wantAssertionsSigned: true,
wantMessageSigned: true,
acsBinding: 'redirect',
signatureConfig: {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
},
relayState: 'https://example.com/relay',
},
},
])('should validate $name', ({ request }) => {
const result = SamlPreferences.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid loginBinding',
request: {
loginBinding: 'invalid',
},
expectedErrorPath: ['loginBinding'],
},
{
name: 'invalid acsBinding',
request: {
acsBinding: 'invalid',
},
expectedErrorPath: ['acsBinding'],
},
{
name: 'invalid signatureConfig location action',
request: {
signatureConfig: {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'invalid',
},
},
},
expectedErrorPath: ['signatureConfig', 'location', 'action'],
},
{
name: 'missing signatureConfig location reference',
request: {
signatureConfig: {
prefix: 'ds',
location: {
action: 'after',
},
},
},
expectedErrorPath: ['signatureConfig', 'location', 'reference'],
},
{
name: 'invalid mapping email',
request: {
mapping: {
email: 123,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
},
},
expectedErrorPath: ['mapping', 'email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = SamlPreferences.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
describe('Edge cases', () => {
test('should handle optional fields correctly', () => {
const validRequest = {
mapping: undefined,
metadata: undefined,
metadataUrl: undefined,
loginEnabled: undefined,
loginLabel: undefined,
};
const result = SamlPreferences.safeParse(validRequest);
expect(result.success).toBe(true);
});
test('should handle default values correctly', () => {
const validRequest = {};
const result = SamlPreferences.safeParse(validRequest);
expect(result.success).toBe(true);
expect(result.data?.ignoreSSL).toBe(false);
expect(result.data?.loginBinding).toBe('redirect');
expect(result.data?.authnRequestsSigned).toBe(false);
expect(result.data?.wantAssertionsSigned).toBe(true);
expect(result.data?.wantMessageSigned).toBe(true);
expect(result.data?.acsBinding).toBe('post');
expect(result.data?.signatureConfig).toEqual({
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
});
expect(result.data?.relayState).toBe('');
});
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class SamlAcsDto extends Z.class({
RelayState: z.string().optional(),
}) {}

View file

@ -0,0 +1,50 @@
import { z } from 'zod';
import { Z } from 'zod-class';
const SamlLoginBindingSchema = z.enum(['redirect', 'post']);
/** Schema for configuring the signature in SAML requests/responses. */
const SignatureConfigSchema = z.object({
prefix: z.string().default('ds'),
location: z.object({
reference: z.string(),
action: z.enum(['before', 'after', 'prepend', 'append']),
}),
});
export class SamlPreferences extends Z.class({
/** Mapping of SAML attributes to user fields. */
mapping: z
.object({
email: z.string(),
firstName: z.string(),
lastName: z.string(),
userPrincipalName: z.string(),
})
.optional(),
/** SAML metadata in XML format. */
metadata: z.string().optional(),
metadataUrl: z.string().optional(),
ignoreSSL: z.boolean().default(false),
loginBinding: SamlLoginBindingSchema.default('redirect'),
/** Whether SAML login is enabled. */
loginEnabled: z.boolean().optional(),
/** Label for the SAML login button. on the Auth screen */
loginLabel: z.string().optional(),
authnRequestsSigned: z.boolean().default(false),
wantAssertionsSigned: z.boolean().default(true),
wantMessageSigned: z.boolean().default(true),
acsBinding: SamlLoginBindingSchema.default('post'),
signatureConfig: SignatureConfigSchema.default({
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
}),
relayState: z.string().default(''),
}) {}

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class SamlToggleDto extends Z.class({
loginEnabled: z.boolean(),
}) {}

View file

@ -4,7 +4,7 @@ import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.r
import { UserRepository } from '@/databases/repositories/user.repository';
import { generateNanoId } from '@/databases/utils/generators';
import * as helpers from '@/sso.ee/saml/saml-helpers';
import type { SamlUserAttributes } from '@/sso.ee/saml/types/saml-user-attributes';
import type { SamlUserAttributes } from '@/sso.ee/saml/types';
import { mockInstance } from '@test/mocking';
const userRepository = mockInstance(UserRepository);

View file

@ -1,11 +1,15 @@
import { Logger } from 'n8n-core';
import { mock } from 'jest-mock-extended';
import { mockInstance } from '@test/mocking';
import { validateMetadata, validateResponse } from '../saml-validator';
import { SamlValidator } from '../saml-validator';
describe('saml-validator', () => {
mockInstance(Logger);
const validator = new SamlValidator(mock());
const VALID_CERTIFICATE =
'MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT';
beforeAll(async () => {
await validator.init();
});
describe('validateMetadata', () => {
test('successfully validates metadata containing ws federation tags', async () => {
@ -31,8 +35,7 @@ describe('saml-validator', () => {
DQnnT/5se4dqYN86R35MCdbyKVl64lGPLSIVrxFxrOQ9YRK1br7Z1Bt1/LQD4f92z+GwAl+9tZTWhuoy6OGHCV6LlqBEztW43KnlCKw6eaNg4/6NluzJ/XeknXYLURDnfFVyGbLQAYWGND4Qm8CUXO/GjGfWTZuArvrDDC36/2FA41jKXtf1InxGFx1Bbaskx3n3KCFFth/V9knbnc1zftEe022aQluPRoGccROOI4ZeLUFL6+1gYlxjx0gFIOTRiuvrzR765lHNrF7iZ4aD+XukqtkGEtxTkiLoB+Bnr8Fd7IF5rV5FKTZWSxo+ZFcLimrDGtFPItVrC/oKRc+MGA==</SignatureValue>
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</ds:X509Certificate>
<ds:X509Certificate>${VALID_CERTIFICATE}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</Signature>
@ -43,8 +46,7 @@ describe('saml-validator', () => {
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
<X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
@ -169,8 +171,7 @@ describe('saml-validator', () => {
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
<X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
@ -194,8 +195,7 @@ describe('saml-validator', () => {
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
<X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
@ -209,7 +209,7 @@ describe('saml-validator', () => {
</EntityDescriptor>`;
// ACT
const result = await validateMetadata(metadata);
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(true);
@ -225,7 +225,85 @@ describe('saml-validator', () => {
</EntityDescriptor>`;
// ACT
const result = await validateMetadata(metadata);
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
});
test('rejects malformed XML metadata', async () => {
// ARRANGE
const metadata = `<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_1069c6df-0612-4058-ae4e-1987ca45431b"
entityID="https://sts.windows.net/random-issuer/"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>${VALID_CERTIFICATE}
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
</IDPSSODescriptor>
`; // Missing closing tags
// ACT
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
});
test('rejects metadata missing SingleSignOnService', async () => {
// ARRANGE
const metadata = `<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_1069c6df-0612-4058-ae4e-1987ca45431b"
entityID="https://sts.windows.net/random-issuer/"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>${VALID_CERTIFICATE}
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
</IDPSSODescriptor>
</EntityDescriptor>`;
// ACT
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
});
test('rejects metadata with invalid X.509 certificate', async () => {
// ARRANGE
const metadata = `<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_1069c6df-0612-4058-ae4e-1987ca45431b"
entityID="https://sts.windows.net/random-issuer/"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>
INVALID_CERTIFICATE
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://login.microsoftonline.com/random-issuer/saml2" />
</IDPSSODescriptor>
</EntityDescriptor>`;
// ACT
const result = await validator.validateMetadata(metadata);
// ASSERT
expect(result).toBe(false);
@ -328,13 +406,13 @@ describe('saml-validator', () => {
</samlp:Response>`;
// ACT
const result = await validateResponse(response);
const result = await validator.validateResponse(response);
// ASSERT
expect(result).toBe(true);
});
test('rejects invalidate response', async () => {
test('rejects invalid response', async () => {
// ARRANGE
// Invalid because required children are missing
const response = `<samlp:Response ID="random_id" Version="2.0"
@ -344,7 +422,45 @@ describe('saml-validator', () => {
</samlp:Response>`;
// ACT
const result = await validateResponse(response);
const result = await validator.validateResponse(response);
// ASSERT
expect(result).toBe(false);
});
test('rejects expired SAML response', async () => {
// ARRANGE
const response = `<samlp:Response ID="random_id" Version="2.0"
IssueInstant="2024-11-13T14:58:00.371Z" Destination="random-url"
InResponseTo="random_id"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
https://sts.windows.net/random-issuer/</Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
<Assertion ID="_random_id" IssueInstant="2024-11-13T14:58:00.367Z"
Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
<Issuer>https://sts.windows.net/random-issuer/</Issuer>
<Subject>
<NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
random_name_id</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData InResponseTo="random_id"
NotOnOrAfter="2023-11-13T15:58:00.284Z" // Expired
Recipient="random-url" />
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="2024-11-13T14:53:00.284Z" NotOnOrAfter="2023-11-13T15:58:00.284Z"> // Expired
<AudienceRestriction>
<Audience>http://localhost:5678/rest/sso/saml/metadata</Audience>
</AudienceRestriction>
</Conditions>
</Assertion>
</samlp:Response>`;
// ACT
const result = await validator.validateResponse(response);
// ASSERT
expect(result).toBe(false);

View file

@ -1,10 +1,8 @@
import type express from 'express';
import { mock } from 'jest-mock-extended';
import { Logger } from 'n8n-core';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { UrlService } from '@/services/url.service';
import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
import { mockInstance } from '@test/mocking';
@ -13,10 +11,8 @@ import { SAML_PREFERENCES_DB_KEY } from '../constants';
import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error';
describe('SamlService', () => {
const logger = mockInstance(Logger);
const urlService = mockInstance(UrlService);
const samlService = new SamlService(logger, urlService);
const settingsRepository = mockInstance(SettingsRepository);
const samlService = new SamlService(mock(), mock(), mock(), mock(), settingsRepository);
beforeEach(() => {
jest.restoreAllMocks();

View file

@ -2,18 +2,14 @@ import { type Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { User } from '@/databases/entities/user';
import { UrlService } from '@/services/url.service';
import { mockInstance } from '@test/mocking';
import type { AuthlessRequest } from '@/requests';
import { SamlService } from '../../saml.service.ee';
import type { SamlService } from '../../saml.service.ee';
import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee';
import type { SamlConfiguration } from '../../types/requests';
import type { SamlUserAttributes } from '../../types/saml-user-attributes';
import type { SamlUserAttributes } from '../../types';
import { SamlController } from '../saml.controller.ee';
const urlService = mockInstance(UrlService);
urlService.getInstanceBaseUrl.mockReturnValue('');
const samlService = mockInstance(SamlService);
const samlService = mock<SamlService>();
const controller = new SamlController(mock(), samlService, mock(), mock());
const user = mock<User>({
@ -31,46 +27,45 @@ const attributes: SamlUserAttributes = {
};
describe('Test views', () => {
const RelayState = getServiceProviderConfigTestReturnUrl();
test('Should render success with template', async () => {
const req = mock<SamlConfiguration.AcsRequest>();
const req = mock<AuthlessRequest>();
const res = mock<Response>();
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
samlService.handleSamlLogin.mockResolvedValueOnce({
authenticatedUser: user,
attributes,
onboardingRequired: false,
});
await controller.acsPost(req, res);
await controller.acsPost(req, res, { RelayState });
expect(res.render).toBeCalledWith('saml-connection-test-success', attributes);
});
test('Should render failure with template', async () => {
const req = mock<SamlConfiguration.AcsRequest>();
const req = mock<AuthlessRequest>();
const res = mock<Response>();
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
samlService.handleSamlLogin.mockResolvedValueOnce({
authenticatedUser: undefined,
attributes,
onboardingRequired: false,
});
await controller.acsPost(req, res);
await controller.acsPost(req, res, { RelayState });
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes });
});
test('Should render error with template', async () => {
const req = mock<SamlConfiguration.AcsRequest>();
const req = mock<AuthlessRequest>();
const res = mock<Response>();
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
samlService.handleSamlLogin.mockRejectedValueOnce(new Error('Test Error'));
await controller.acsPost(req, res);
await controller.acsPost(req, res, { RelayState });
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: 'Test Error' });
});

View file

@ -1,15 +1,14 @@
import { validate } from 'class-validator';
import express from 'express';
import { SamlAcsDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import { Response } from 'express';
import querystring from 'querystring';
import type { PostBindingContext } from 'samlify/types/src/entity';
import url from 'url';
import { AuthService } from '@/auth/auth.service';
import { Get, Post, RestController, GlobalScope } from '@/decorators';
import { Get, Post, RestController, GlobalScope, Body } from '@/decorators';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { AuthenticatedRequest } from '@/requests';
import { AuthenticatedRequest, AuthlessRequest } from '@/requests';
import { sendErrorResponse } from '@/response-helper';
import { UrlService } from '@/services/url.service';
@ -25,7 +24,6 @@ import {
getServiceProviderReturnUrl,
} from '../service-provider.ee';
import type { SamlLoginBinding } from '../types';
import { SamlConfiguration } from '../types/requests';
import { getInitSSOFormView } from '../views/init-sso-post';
@RestController('/sso/saml')
@ -38,7 +36,7 @@ export class SamlController {
) {}
@Get('/metadata', { skipAuth: true })
async getServiceProviderMetadata(_: express.Request, res: express.Response) {
async getServiceProviderMetadata(_: AuthlessRequest, res: Response) {
return res
.header('Content-Type', 'text/xml')
.send(this.samlService.getServiceProviderInstance().getMetadata());
@ -62,17 +60,8 @@ export class SamlController {
*/
@Post('/config', { middlewares: [samlLicensedMiddleware] })
@GlobalScope('saml:manage')
async configPost(req: SamlConfiguration.Update) {
const validationResult = await validate(req.body);
if (validationResult.length === 0) {
const result = await this.samlService.setSamlPreferences(req.body);
return result;
} else {
throw new BadRequestError(
'Body is not a valid SamlPreferences object: ' +
validationResult.map((e) => e.toString()).join(','),
);
}
async configPost(_req: AuthenticatedRequest, _res: Response, @Body payload: SamlPreferences) {
return await this.samlService.setSamlPreferences(payload);
}
/**
@ -80,11 +69,12 @@ export class SamlController {
*/
@Post('/config/toggle', { middlewares: [samlLicensedMiddleware] })
@GlobalScope('saml:manage')
async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) {
if (req.body.loginEnabled === undefined) {
throw new BadRequestError('Body should contain a boolean "loginEnabled" property');
}
await this.samlService.setSamlPreferences({ loginEnabled: req.body.loginEnabled });
async toggleEnabledPost(
_req: AuthenticatedRequest,
res: Response,
@Body { loginEnabled }: SamlToggleDto,
) {
await this.samlService.setSamlPreferences({ loginEnabled });
return res.sendStatus(200);
}
@ -92,7 +82,7 @@ export class SamlController {
* Assertion Consumer Service endpoint
*/
@Get('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) {
async acsGet(req: AuthlessRequest, res: Response) {
return await this.acsHandler(req, res, 'redirect');
}
@ -100,8 +90,8 @@ export class SamlController {
* Assertion Consumer Service endpoint
*/
@Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) {
return await this.acsHandler(req, res, 'post');
async acsPost(req: AuthlessRequest, res: Response, @Body payload: SamlAcsDto) {
return await this.acsHandler(req, res, 'post', payload);
}
/**
@ -110,14 +100,15 @@ export class SamlController {
* For test connections, returns status 202 if SAML is not enabled
*/
private async acsHandler(
req: SamlConfiguration.AcsRequest,
res: express.Response,
req: AuthlessRequest,
res: Response,
binding: SamlLoginBinding,
payload: SamlAcsDto = {},
) {
try {
const loginResult = await this.samlService.handleSamlLogin(req, binding);
// if RelayState is set to the test connection Url, this is a test connection
if (isConnectionTestRequest(req)) {
if (isConnectionTestRequest(payload)) {
if (loginResult.authenticatedUser) {
return res.render('saml-connection-test-success', loginResult.attributes);
} else {
@ -139,7 +130,7 @@ export class SamlController {
if (loginResult.onboardingRequired) {
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
} else {
const redirectUrl = req.body?.RelayState ?? '/';
const redirectUrl = payload.RelayState ?? '/';
return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl);
}
} else {
@ -153,7 +144,7 @@ export class SamlController {
// Need to manually send the error response since we're using templates
return sendErrorResponse(res, new AuthError('SAML Authentication failed'));
} catch (error) {
if (isConnectionTestRequest(req)) {
if (isConnectionTestRequest(payload)) {
return res.render('saml-connection-test-failed', { message: (error as Error).message });
}
this.eventService.emit('user-login-failed', {
@ -173,7 +164,7 @@ export class SamlController {
* This endpoint is available if SAML is licensed and enabled
*/
@Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true })
async initSsoGet(req: express.Request, res: express.Response) {
async initSsoGet(req: AuthlessRequest, res: Response) {
let redirectUrl = '';
try {
const refererUrl = req.headers.referer;
@ -198,11 +189,11 @@ export class SamlController {
*/
@Get('/config/test', { middlewares: [samlLicensedMiddleware] })
@GlobalScope('saml:manage')
async configTestGet(_: AuthenticatedRequest, res: express.Response) {
async configTestGet(_: AuthenticatedRequest, res: Response) {
return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl());
}
private async handleInitSSO(res: express.Response, relayState?: string) {
private async handleInitSSO(res: Response, relayState?: string) {
const result = await this.samlService.getLoginRequestUrl(relayState);
if (result?.binding === 'redirect') {
return result.context.context;

View file

@ -1,3 +1,4 @@
import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types';
import { randomString } from 'n8n-workflow';
import type { FlowResult } from 'samlify/types/src/flow';
import { Container } from 'typedi';
@ -14,10 +15,7 @@ import { PasswordUtility } from '@/services/password.utility';
import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee';
import type { SamlConfiguration } from './types/requests';
import type { SamlAttributeMapping } from './types/saml-attribute-mapping';
import type { SamlPreferences } from './types/saml-preferences';
import type { SamlUserAttributes } from './types/saml-user-attributes';
import type { SamlAttributeMapping, SamlUserAttributes } from './types';
import {
getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod,
@ -165,6 +163,6 @@ export function getMappedSamlAttributesFromFlowResult(
return result;
}
export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean {
return req.body.RelayState === getServiceProviderConfigTestReturnUrl();
export function isConnectionTestRequest(payload: SamlAcsDto): boolean {
return payload.RelayState === getServiceProviderConfigTestReturnUrl();
}

View file

@ -1,115 +1,87 @@
import { Logger } from 'n8n-core';
import { Container } from 'typedi';
import type { XMLFileInfo } from 'xmllint-wasm';
import { Service } from 'typedi';
import type { XMLFileInfo, XMLLintOptions, XMLValidationResult } from 'xmllint-wasm';
let xmlMetadata: XMLFileInfo;
let xmlProtocol: XMLFileInfo;
@Service()
export class SamlValidator {
private xmlMetadata: XMLFileInfo;
let preload: XMLFileInfo[] = [];
private xmlProtocol: XMLFileInfo;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
let xmllintWasm: typeof import('xmllint-wasm') | undefined;
private preload: XMLFileInfo[] = [];
// dynamically load schema files
async function loadSchemas(): Promise<void> {
xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo;
xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo;
preload = (
await Promise.all([
// SAML
import('./schema/saml-schema-assertion-2.0.xsd'),
import('./schema/xmldsig-core-schema.xsd'),
import('./schema/xenc-schema.xsd'),
import('./schema/xml.xsd'),
constructor(private readonly logger: Logger) {}
// WS-Federation
import('./schema/ws-federation.xsd'),
import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'),
import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'),
import('./schema/ws-addr.xsd'),
import('./schema/metadata-exchange.xsd'),
import('./schema/ws-securitypolicy-1.2.xsd'),
import('./schema/ws-authorization.xsd'),
])
).map((m) => m.xmlFileInfo);
}
private xmllint: {
validateXML: (options: XMLLintOptions) => Promise<XMLValidationResult>;
};
// dynamically load xmllint-wasm
async function loadXmllintWasm(): Promise<void> {
if (xmllintWasm === undefined) {
Container.get(Logger).debug('Loading xmllint-wasm library into memory');
xmllintWasm = await import('xmllint-wasm');
async init() {
await this.loadSchemas();
this.xmllint = await import('xmllint-wasm');
}
}
export async function validateMetadata(metadata: string): Promise<boolean> {
const logger = Container.get(Logger);
try {
await loadXmllintWasm();
await loadSchemas();
const validationResult = await xmllintWasm?.validateXML({
xml: [
{
fileName: 'metadata.xml',
contents: metadata,
},
],
extension: 'schema',
schema: [xmlMetadata],
preload: [xmlProtocol, ...preload],
});
if (validationResult?.valid) {
logger.debug('SAML Metadata is valid');
return true;
} else {
logger.warn('SAML Validate Metadata: Invalid metadata');
logger.warn(
validationResult
? validationResult.errors
.map((error) => `${error.message} - ${error.rawMessage}`)
.join('\n')
: '',
);
async validateMetadata(metadata: string): Promise<boolean> {
return await this.validateXml('metadata', metadata);
}
async validateResponse(response: string): Promise<boolean> {
return await this.validateXml('response', response);
}
// dynamically load schema files
private async loadSchemas(): Promise<void> {
this.xmlProtocol = (await import('./schema/saml-schema-protocol-2.0.xsd')).xmlFileInfo;
this.xmlMetadata = (await import('./schema/saml-schema-metadata-2.0.xsd')).xmlFileInfo;
this.preload = (
await Promise.all([
// SAML
import('./schema/saml-schema-assertion-2.0.xsd'),
import('./schema/xmldsig-core-schema.xsd'),
import('./schema/xenc-schema.xsd'),
import('./schema/xml.xsd'),
// WS-Federation
import('./schema/ws-federation.xsd'),
import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'),
import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'),
import('./schema/ws-addr.xsd'),
import('./schema/metadata-exchange.xsd'),
import('./schema/ws-securitypolicy-1.2.xsd'),
import('./schema/ws-authorization.xsd'),
])
).map((m) => m.xmlFileInfo);
}
private async validateXml(type: 'metadata' | 'response', contents: string): Promise<boolean> {
const fileName = `${type}.xml`;
const schema = type === 'metadata' ? [this.xmlMetadata] : [this.xmlProtocol];
const preload = [type === 'metadata' ? this.xmlProtocol : this.xmlMetadata, ...this.preload];
try {
const validationResult = await this.xmllint.validateXML({
xml: [{ fileName, contents }],
extension: 'schema',
schema,
preload,
});
if (validationResult?.valid) {
this.logger.debug(`SAML ${type} is valid`);
return true;
} else {
this.logger.debug(`SAML ${type} is invalid`);
this.logger.warn(
validationResult
? validationResult.errors
.map((error) => `${error.message} - ${error.rawMessage}`)
.join('\n')
: '',
);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.logger.warn(error);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger.warn(error);
return false;
}
return false;
}
export async function validateResponse(response: string): Promise<boolean> {
const logger = Container.get(Logger);
try {
await loadXmllintWasm();
await loadSchemas();
const validationResult = await xmllintWasm?.validateXML({
xml: [
{
fileName: 'response.xml',
contents: response,
},
],
extension: 'schema',
schema: [xmlProtocol],
preload: [xmlMetadata, ...preload],
});
if (validationResult?.valid) {
logger.debug('SAML Response is valid');
return true;
} else {
logger.warn('SAML Validate Response: Failed');
logger.warn(
validationResult
? validationResult.errors
.map((error) => `${error.message} - ${error.rawMessage}`)
.join('\n')
: '',
);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger.warn(error);
}
return false;
}

View file

@ -1,3 +1,4 @@
import type { SamlPreferences } from '@n8n/api-types';
import axios from 'axios';
import type express from 'express';
import https from 'https';
@ -5,7 +6,7 @@ import { Logger } from 'n8n-core';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
import Container, { Service } from 'typedi';
import { Service } from 'typedi';
import type { Settings } from '@/databases/entities/settings';
import type { User } from '@/databases/entities/user';
@ -27,11 +28,9 @@ import {
setSamlLoginLabel,
updateUserFromSamlAttributes,
} from './saml-helpers';
import { validateMetadata, validateResponse } from './saml-validator';
import { SamlValidator } from './saml-validator';
import { getServiceProviderInstance } from './service-provider.ee';
import type { SamlLoginBinding } from './types';
import type { SamlPreferences } from './types/saml-preferences';
import type { SamlUserAttributes } from './types/saml-user-attributes';
import type { SamlLoginBinding, SamlUserAttributes } from './types';
import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers';
@Service()
@ -79,12 +78,16 @@ export class SamlService {
constructor(
private readonly logger: Logger,
private readonly urlService: UrlService,
private readonly validator: SamlValidator,
private readonly userRepository: UserRepository,
private readonly settingsRepository: SettingsRepository,
) {}
async init(): Promise<void> {
try {
// load preferences first but do not apply so as to not load samlify unnecessarily
await this.loadFromDbAndApplySamlPreferences(false);
await this.validator.init();
if (isSamlLicensedAndEnabled()) {
await this.loadSamlify();
await this.loadFromDbAndApplySamlPreferences(true);
@ -108,9 +111,10 @@ export class SamlService {
this.logger.debug('Loading samlify library into memory');
this.samlify = await import('samlify');
}
this.samlify.setSchemaValidator({
validate: async (response: string) => {
const valid = await validateResponse(response);
const valid = await this.validator.validateResponse(response);
if (!valid) {
throw new InvalidSamlMetadataError();
}
@ -188,7 +192,7 @@ export class SamlService {
const attributes = await this.getAttributesFromLoginResponse(req, binding);
if (attributes.email) {
const lowerCasedEmail = attributes.email.toLowerCase();
const user = await Container.get(UserRepository).findOne({
const user = await this.userRepository.findOne({
where: { email: lowerCasedEmail },
relations: ['authIdentities'],
});
@ -233,7 +237,7 @@ export class SamlService {
};
}
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> {
async setSamlPreferences(prefs: Partial<SamlPreferences>): Promise<SamlPreferences | undefined> {
await this.loadSamlify();
await this.loadPreferencesWithoutValidation(prefs);
if (prefs.metadataUrl) {
@ -242,7 +246,7 @@ export class SamlService {
this._samlPreferences.metadata = fetchedMetadata;
}
} else if (prefs.metadata) {
const validationResult = await validateMetadata(prefs.metadata);
const validationResult = await this.validator.validateMetadata(prefs.metadata);
if (!validationResult) {
throw new InvalidSamlMetadataError();
}
@ -252,7 +256,7 @@ export class SamlService {
return result;
}
async loadPreferencesWithoutValidation(prefs: SamlPreferences) {
async loadPreferencesWithoutValidation(prefs: Partial<SamlPreferences>) {
this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
@ -278,7 +282,7 @@ export class SamlService {
}
async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> {
const samlPreferences = await Container.get(SettingsRepository).findOne({
const samlPreferences = await this.settingsRepository.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
if (samlPreferences) {
@ -296,18 +300,18 @@ export class SamlService {
}
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
const samlPreferences = await Container.get(SettingsRepository).findOne({
const samlPreferences = await this.settingsRepository.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
const settingsValue = JSON.stringify(this.samlPreferences);
let result: Settings;
if (samlPreferences) {
samlPreferences.value = settingsValue;
result = await Container.get(SettingsRepository).save(samlPreferences, {
result = await this.settingsRepository.save(samlPreferences, {
transaction: false,
});
} else {
result = await Container.get(SettingsRepository).save(
result = await this.settingsRepository.save(
{
key: SAML_PREFERENCES_DB_KEY,
value: settingsValue,
@ -332,7 +336,7 @@ export class SamlService {
const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent });
if (response.status === 200 && response.data) {
const xml = (await response.data) as string;
const validationResult = await validateMetadata(xml);
const validationResult = await this.validator.validateMetadata(xml);
if (!validationResult) {
throw new BadRequestError(
`Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`,
@ -392,6 +396,6 @@ export class SamlService {
*/
async reset() {
await setSamlLoginEnabled(false);
await Container.get(SettingsRepository).delete({ key: SAML_PREFERENCES_DB_KEY });
await this.settingsRepository.delete({ key: SAML_PREFERENCES_DB_KEY });
}
}

View file

@ -1,10 +1,9 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { ServiceProviderInstance } from 'samlify';
import { Container } from 'typedi';
import { UrlService } from '@/services/url.service';
import type { SamlPreferences } from './types/saml-preferences';
let serviceProviderInstance: ServiceProviderInstance | undefined;
export function getServiceProviderEntityId(): string {

View file

@ -0,0 +1,5 @@
import type { SamlPreferences } from '@n8n/api-types';
export type SamlLoginBinding = SamlPreferences['loginBinding'];
export type SamlAttributeMapping = NonNullable<SamlPreferences['mapping']>;
export type SamlUserAttributes = SamlAttributeMapping;

View file

@ -1 +0,0 @@
export type SamlLoginBinding = 'post' | 'redirect';

View file

@ -1,17 +0,0 @@
import type { AuthenticatedRequest, AuthlessRequest } from '@/requests';
import type { SamlPreferences } from './saml-preferences';
export declare namespace SamlConfiguration {
type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>;
type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>;
type AcsRequest = AuthlessRequest<
{},
{},
{
RelayState?: string;
},
{}
>;
}

View file

@ -1,6 +0,0 @@
export interface SamlAttributeMapping {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
}

View file

@ -1,65 +0,0 @@
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
import { SignatureConfig } from 'samlify/types/src/types';
import { SamlLoginBinding } from '.';
import { SamlAttributeMapping } from './saml-attribute-mapping';
export class SamlPreferences {
@IsObject()
@IsOptional()
mapping?: SamlAttributeMapping;
@IsString()
@IsOptional()
metadata?: string;
@IsString()
@IsOptional()
metadataUrl?: string;
@IsBoolean()
@IsOptional()
ignoreSSL?: boolean = false;
@IsString()
@IsOptional()
loginBinding?: SamlLoginBinding = 'redirect';
@IsBoolean()
@IsOptional()
loginEnabled?: boolean;
@IsString()
@IsOptional()
loginLabel?: string;
@IsBoolean()
@IsOptional()
authnRequestsSigned?: boolean = false;
@IsBoolean()
@IsOptional()
wantAssertionsSigned?: boolean = true;
@IsBoolean()
@IsOptional()
wantMessageSigned?: boolean = true;
@IsString()
@IsOptional()
acsBinding?: SamlLoginBinding = 'post';
@IsObject()
@IsOptional()
signatureConfig?: SignatureConfig = {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
};
@IsString()
@IsOptional()
relayState?: string = '';
}

View file

@ -1,6 +0,0 @@
export interface SamlUserAttributes {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
}

View file

@ -1,5 +1,5 @@
import * as helpers from '@/sso.ee/saml/saml-helpers';
import type { SamlUserAttributes } from '@/sso.ee/saml/types/saml-user-attributes';
import type { SamlUserAttributes } from '@/sso.ee/saml/types';
import { getPersonalProject } from '../shared/db/projects';
import * as testDb from '../shared/test-db';

View file

@ -34,6 +34,8 @@ beforeAll(async () => {
authMemberAgent = testServer.authAgentFor(someUser);
});
beforeEach(async () => await enableSaml(false));
describe('Instance owner', () => {
describe('PATCH /me', () => {
test('should succeed with valid inputs', async () => {
@ -89,6 +91,17 @@ describe('Instance owner', () => {
.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', () => {

File diff suppressed because one or more lines are too long

View file

@ -209,8 +209,10 @@ export const setupTestServer = ({
break;
case 'saml':
const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers');
const { SamlService } = await import('@/sso.ee/saml/saml.service.ee');
await Container.get(SamlService).init();
await import('@/sso.ee/saml/routes/saml.controller.ee');
const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers');
await setSamlLoginEnabled(true);
break;

View file

@ -56,7 +56,6 @@ import type {
ROLE,
} from '@/constants';
import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy } from '@/utils/typeHelpers';
import type { ProjectSharingData } from '@/types/projects.types';
@ -1300,41 +1299,6 @@ export type ExecutionsQueryFilter = {
vote?: ExecutionFilterVote;
};
export type SamlAttributeMapping = {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
};
export type SamlLoginBinding = 'post' | 'redirect';
export type SamlSignatureConfig = {
prefix: 'ds';
location: {
reference: '/samlp:Response/saml:Issuer';
action: 'after';
};
};
export type SamlPreferencesLoginEnabled = {
loginEnabled: boolean;
};
export type SamlPreferences = {
mapping?: SamlAttributeMapping;
metadata?: string;
metadataUrl?: string;
ignoreSSL?: boolean;
loginBinding?: SamlLoginBinding;
acsBinding?: SamlLoginBinding;
authnRequestsSigned?: boolean;
loginLabel?: string;
wantAssertionsSigned?: boolean;
wantMessageSigned?: boolean;
signatureConfig?: SamlSignatureConfig;
} & PartialBy<SamlPreferencesLoginEnabled, 'loginEnabled'>;
export type SamlPreferencesExtractedData = {
entityID: string;
returnUrl: string;

View file

@ -1,16 +1,17 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs';
import type { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface';
import type { SamlPreferencesExtractedData } from '@/Interface';
import { faker } from '@faker-js/faker';
import type { AppSchema } from '@/__tests__/server/types';
import { jsonParse } from 'n8n-workflow';
let samlConfig: SamlPreferences & SamlPreferencesExtractedData = {
let samlConfig = {
metadata: '<?xml version="1.0"?>',
metadataUrl: '',
entityID: faker.internet.url(),
returnUrl: faker.internet.url(),
};
} as SamlPreferences & SamlPreferencesExtractedData;
export function routesForSSO(server: Server) {
server.get('/rest/sso/saml/config', () => {

View file

@ -1,10 +1,6 @@
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type {
IRestApiContext,
SamlPreferencesLoginEnabled,
SamlPreferences,
SamlPreferencesExtractedData,
} from '@/Interface';
import type { IRestApiContext, SamlPreferencesExtractedData } from '@/Interface';
export const initSSO = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/initsso');
@ -22,14 +18,14 @@ export const getSamlConfig = async (
export const saveSamlConfig = async (
context: IRestApiContext,
data: SamlPreferences,
data: Partial<SamlPreferences>,
): Promise<SamlPreferences | undefined> => {
return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data);
};
export const toggleSamlConfig = async (
context: IRestApiContext,
data: SamlPreferencesLoginEnabled,
data: SamlToggleDto,
): Promise<void> => {
return await makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data);
};

View file

@ -1,10 +1,11 @@
import type { SamlPreferences } from '@n8n/api-types';
import { computed, reactive } from 'vue';
import { defineStore } from 'pinia';
import { EnterpriseEditionFeature } from '@/constants';
import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store';
import * as ssoApi from '@/api/sso';
import type { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface';
import type { SamlPreferencesExtractedData } from '@/Interface';
import { updateCurrentUser } from '@/api/users';
import { useUsersStore } from '@/stores/users.store';
@ -64,7 +65,7 @@ export const useSSOStore = defineStore('sso', () => {
state.samlConfig = samlConfig;
return samlConfig;
};
const saveSamlConfig = async (config: SamlPreferences) =>
const saveSamlConfig = async (config: Partial<SamlPreferences>) =>
await ssoApi.saveSamlConfig(rootStore.restApiContext, config);
const testSamlConfig = async () => await ssoApi.testSamlConfig(rootStore.restApiContext);

View file

@ -1,3 +1,4 @@
import type { SamlPreferences } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { within, waitFor } from '@testing-library/vue';
import { mockedStore, retry } from '@/__tests__/utils';
@ -11,6 +12,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import { EnterpriseEditionFeature } from '@/constants';
import { nextTick } from 'vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import type { SamlPreferencesExtractedData } from '@/Interface';
const renderView = createComponentRenderer(SettingsSso);
@ -20,7 +22,7 @@ const samlConfig = {
'https://dev-qqkrykgkoo0p63d5.eu.auth0.com/samlp/metadata/KR1cSrRrxaZT2gV8ZhPAUIUHtEY4duhN',
entityID: 'https://n8n-tunnel.myhost.com/rest/sso/saml/metadata',
returnUrl: 'https://n8n-tunnel.myhost.com/rest/sso/saml/acs',
};
} as SamlPreferences & SamlPreferencesExtractedData;
const telemetryTrack = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
@ -133,7 +135,7 @@ describe('SettingsSso View', () => {
const urlinput = getByTestId('sso-provider-url');
expect(urlinput).toBeVisible();
await userEvent.type(urlinput, samlConfig.metadataUrl);
await userEvent.type(urlinput, samlConfig.metadataUrl!);
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
@ -172,7 +174,7 @@ describe('SettingsSso View', () => {
const xmlInput = getByTestId('sso-provider-xml');
expect(xmlInput).toBeVisible();
await userEvent.type(xmlInput, samlConfig.metadata);
await userEvent.type(xmlInput, samlConfig.metadata!);
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);