mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(core): Extract SAML requests payloads into DTOs (#12435)
This commit is contained in:
parent
2241eef8cf
commit
552cff1860
|
@ -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';
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
6
packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class SamlAcsDto extends Z.class({
|
||||
RelayState: z.string().optional(),
|
||||
}) {}
|
50
packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts
Normal file
50
packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts
Normal 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(''),
|
||||
}) {}
|
6
packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class SamlToggleDto extends Z.class({
|
||||
loginEnabled: z.boolean(),
|
||||
}) {}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
5
packages/cli/src/sso.ee/saml/types.ts
Normal file
5
packages/cli/src/sso.ee/saml/types.ts
Normal 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;
|
|
@ -1 +0,0 @@
|
|||
export type SamlLoginBinding = 'post' | 'redirect';
|
|
@ -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;
|
||||
},
|
||||
{}
|
||||
>;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export interface SamlAttributeMapping {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
userPrincipalName: string;
|
||||
}
|
|
@ -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 = '';
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export interface SamlUserAttributes {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
userPrincipalName: string;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue