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 { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
|
||||||
export { ChangePasswordRequestDto } from './password-reset/change-password-request.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 { PasswordUpdateRequestDto } from './user/password-update-request.dto';
|
||||||
export { RoleChangeRequestDto } from './user/role-change-request.dto';
|
export { RoleChangeRequestDto } from './user/role-change-request.dto';
|
||||||
export { SettingsUpdateRequestDto } from './user/settings-update-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 { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
import { generateNanoId } from '@/databases/utils/generators';
|
import { generateNanoId } from '@/databases/utils/generators';
|
||||||
import * as helpers from '@/sso.ee/saml/saml-helpers';
|
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';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
const userRepository = mockInstance(UserRepository);
|
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 { SamlValidator } from '../saml-validator';
|
||||||
|
|
||||||
import { validateMetadata, validateResponse } from '../saml-validator';
|
|
||||||
|
|
||||||
describe('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', () => {
|
describe('validateMetadata', () => {
|
||||||
test('successfully validates metadata containing ws federation tags', async () => {
|
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>
|
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:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
<ds:X509Data>
|
<ds:X509Data>
|
||||||
<ds:X509Certificate>
|
<ds:X509Certificate>${VALID_CERTIFICATE}</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:X509Data>
|
</ds:X509Data>
|
||||||
</ds:KeyInfo>
|
</ds:KeyInfo>
|
||||||
</Signature>
|
</Signature>
|
||||||
|
@ -43,8 +46,7 @@ describe('saml-validator', () => {
|
||||||
<KeyDescriptor use="signing">
|
<KeyDescriptor use="signing">
|
||||||
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
<X509Data>
|
<X509Data>
|
||||||
<X509Certificate>
|
<X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
|
||||||
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
|
|
||||||
</X509Data>
|
</X509Data>
|
||||||
</KeyInfo>
|
</KeyInfo>
|
||||||
</KeyDescriptor>
|
</KeyDescriptor>
|
||||||
|
@ -169,8 +171,7 @@ describe('saml-validator', () => {
|
||||||
<KeyDescriptor use="signing">
|
<KeyDescriptor use="signing">
|
||||||
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
<X509Data>
|
<X509Data>
|
||||||
<X509Certificate>
|
<X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
|
||||||
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
|
|
||||||
</X509Data>
|
</X509Data>
|
||||||
</KeyInfo>
|
</KeyInfo>
|
||||||
</KeyDescriptor>
|
</KeyDescriptor>
|
||||||
|
@ -194,8 +195,7 @@ describe('saml-validator', () => {
|
||||||
<KeyDescriptor use="signing">
|
<KeyDescriptor use="signing">
|
||||||
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||||
<X509Data>
|
<X509Data>
|
||||||
<X509Certificate>
|
<X509Certificate>${VALID_CERTIFICATE}</X509Certificate>
|
||||||
MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT</X509Certificate>
|
|
||||||
</X509Data>
|
</X509Data>
|
||||||
</KeyInfo>
|
</KeyInfo>
|
||||||
</KeyDescriptor>
|
</KeyDescriptor>
|
||||||
|
@ -209,7 +209,7 @@ describe('saml-validator', () => {
|
||||||
</EntityDescriptor>`;
|
</EntityDescriptor>`;
|
||||||
|
|
||||||
// ACT
|
// ACT
|
||||||
const result = await validateMetadata(metadata);
|
const result = await validator.validateMetadata(metadata);
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
@ -225,7 +225,85 @@ describe('saml-validator', () => {
|
||||||
</EntityDescriptor>`;
|
</EntityDescriptor>`;
|
||||||
|
|
||||||
// ACT
|
// 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
|
// ASSERT
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
@ -328,13 +406,13 @@ describe('saml-validator', () => {
|
||||||
</samlp:Response>`;
|
</samlp:Response>`;
|
||||||
|
|
||||||
// ACT
|
// ACT
|
||||||
const result = await validateResponse(response);
|
const result = await validator.validateResponse(response);
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalidate response', async () => {
|
test('rejects invalid response', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
// Invalid because required children are missing
|
// Invalid because required children are missing
|
||||||
const response = `<samlp:Response ID="random_id" Version="2.0"
|
const response = `<samlp:Response ID="random_id" Version="2.0"
|
||||||
|
@ -344,7 +422,45 @@ describe('saml-validator', () => {
|
||||||
</samlp:Response>`;
|
</samlp:Response>`;
|
||||||
|
|
||||||
// ACT
|
// 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
|
// ASSERT
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import { Logger } from 'n8n-core';
|
|
||||||
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
||||||
|
|
||||||
import { SettingsRepository } from '@/databases/repositories/settings.repository';
|
import { SettingsRepository } from '@/databases/repositories/settings.repository';
|
||||||
import { UrlService } from '@/services/url.service';
|
|
||||||
import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
|
import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
|
||||||
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
|
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
@ -13,10 +11,8 @@ import { SAML_PREFERENCES_DB_KEY } from '../constants';
|
||||||
import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error';
|
import { InvalidSamlMetadataError } from '../errors/invalid-saml-metadata.error';
|
||||||
|
|
||||||
describe('SamlService', () => {
|
describe('SamlService', () => {
|
||||||
const logger = mockInstance(Logger);
|
|
||||||
const urlService = mockInstance(UrlService);
|
|
||||||
const samlService = new SamlService(logger, urlService);
|
|
||||||
const settingsRepository = mockInstance(SettingsRepository);
|
const settingsRepository = mockInstance(SettingsRepository);
|
||||||
|
const samlService = new SamlService(mock(), mock(), mock(), mock(), settingsRepository);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|
|
@ -2,18 +2,14 @@ import { type Response } from 'express';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { UrlService } from '@/services/url.service';
|
import type { AuthlessRequest } from '@/requests';
|
||||||
import { mockInstance } from '@test/mocking';
|
|
||||||
|
|
||||||
import { SamlService } from '../../saml.service.ee';
|
import type { SamlService } from '../../saml.service.ee';
|
||||||
import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee';
|
import { getServiceProviderConfigTestReturnUrl } from '../../service-provider.ee';
|
||||||
import type { SamlConfiguration } from '../../types/requests';
|
import type { SamlUserAttributes } from '../../types';
|
||||||
import type { SamlUserAttributes } from '../../types/saml-user-attributes';
|
|
||||||
import { SamlController } from '../saml.controller.ee';
|
import { SamlController } from '../saml.controller.ee';
|
||||||
|
|
||||||
const urlService = mockInstance(UrlService);
|
const samlService = mock<SamlService>();
|
||||||
urlService.getInstanceBaseUrl.mockReturnValue('');
|
|
||||||
const samlService = mockInstance(SamlService);
|
|
||||||
const controller = new SamlController(mock(), samlService, mock(), mock());
|
const controller = new SamlController(mock(), samlService, mock(), mock());
|
||||||
|
|
||||||
const user = mock<User>({
|
const user = mock<User>({
|
||||||
|
@ -31,46 +27,45 @@ const attributes: SamlUserAttributes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Test views', () => {
|
describe('Test views', () => {
|
||||||
|
const RelayState = getServiceProviderConfigTestReturnUrl();
|
||||||
|
|
||||||
test('Should render success with template', async () => {
|
test('Should render success with template', async () => {
|
||||||
const req = mock<SamlConfiguration.AcsRequest>();
|
const req = mock<AuthlessRequest>();
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
|
|
||||||
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
|
|
||||||
samlService.handleSamlLogin.mockResolvedValueOnce({
|
samlService.handleSamlLogin.mockResolvedValueOnce({
|
||||||
authenticatedUser: user,
|
authenticatedUser: user,
|
||||||
attributes,
|
attributes,
|
||||||
onboardingRequired: false,
|
onboardingRequired: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await controller.acsPost(req, res);
|
await controller.acsPost(req, res, { RelayState });
|
||||||
|
|
||||||
expect(res.render).toBeCalledWith('saml-connection-test-success', attributes);
|
expect(res.render).toBeCalledWith('saml-connection-test-success', attributes);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should render failure with template', async () => {
|
test('Should render failure with template', async () => {
|
||||||
const req = mock<SamlConfiguration.AcsRequest>();
|
const req = mock<AuthlessRequest>();
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
|
|
||||||
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
|
|
||||||
samlService.handleSamlLogin.mockResolvedValueOnce({
|
samlService.handleSamlLogin.mockResolvedValueOnce({
|
||||||
authenticatedUser: undefined,
|
authenticatedUser: undefined,
|
||||||
attributes,
|
attributes,
|
||||||
onboardingRequired: false,
|
onboardingRequired: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await controller.acsPost(req, res);
|
await controller.acsPost(req, res, { RelayState });
|
||||||
|
|
||||||
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes });
|
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: '', attributes });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should render error with template', async () => {
|
test('Should render error with template', async () => {
|
||||||
const req = mock<SamlConfiguration.AcsRequest>();
|
const req = mock<AuthlessRequest>();
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
|
|
||||||
req.body.RelayState = getServiceProviderConfigTestReturnUrl();
|
|
||||||
samlService.handleSamlLogin.mockRejectedValueOnce(new Error('Test Error'));
|
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' });
|
expect(res.render).toBeCalledWith('saml-connection-test-failed', { message: 'Test Error' });
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import { validate } from 'class-validator';
|
import { SamlAcsDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types';
|
||||||
import express from 'express';
|
import { Response } from 'express';
|
||||||
import querystring from 'querystring';
|
import querystring from 'querystring';
|
||||||
import type { PostBindingContext } from 'samlify/types/src/entity';
|
import type { PostBindingContext } from 'samlify/types/src/entity';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
|
|
||||||
import { AuthService } from '@/auth/auth.service';
|
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 { AuthError } from '@/errors/response-errors/auth.error';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import { AuthenticatedRequest } from '@/requests';
|
import { AuthenticatedRequest, AuthlessRequest } from '@/requests';
|
||||||
import { sendErrorResponse } from '@/response-helper';
|
import { sendErrorResponse } from '@/response-helper';
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
|
@ -25,7 +24,6 @@ import {
|
||||||
getServiceProviderReturnUrl,
|
getServiceProviderReturnUrl,
|
||||||
} from '../service-provider.ee';
|
} from '../service-provider.ee';
|
||||||
import type { SamlLoginBinding } from '../types';
|
import type { SamlLoginBinding } from '../types';
|
||||||
import { SamlConfiguration } from '../types/requests';
|
|
||||||
import { getInitSSOFormView } from '../views/init-sso-post';
|
import { getInitSSOFormView } from '../views/init-sso-post';
|
||||||
|
|
||||||
@RestController('/sso/saml')
|
@RestController('/sso/saml')
|
||||||
|
@ -38,7 +36,7 @@ export class SamlController {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('/metadata', { skipAuth: true })
|
@Get('/metadata', { skipAuth: true })
|
||||||
async getServiceProviderMetadata(_: express.Request, res: express.Response) {
|
async getServiceProviderMetadata(_: AuthlessRequest, res: Response) {
|
||||||
return res
|
return res
|
||||||
.header('Content-Type', 'text/xml')
|
.header('Content-Type', 'text/xml')
|
||||||
.send(this.samlService.getServiceProviderInstance().getMetadata());
|
.send(this.samlService.getServiceProviderInstance().getMetadata());
|
||||||
|
@ -62,17 +60,8 @@ export class SamlController {
|
||||||
*/
|
*/
|
||||||
@Post('/config', { middlewares: [samlLicensedMiddleware] })
|
@Post('/config', { middlewares: [samlLicensedMiddleware] })
|
||||||
@GlobalScope('saml:manage')
|
@GlobalScope('saml:manage')
|
||||||
async configPost(req: SamlConfiguration.Update) {
|
async configPost(_req: AuthenticatedRequest, _res: Response, @Body payload: SamlPreferences) {
|
||||||
const validationResult = await validate(req.body);
|
return await this.samlService.setSamlPreferences(payload);
|
||||||
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(','),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -80,11 +69,12 @@ export class SamlController {
|
||||||
*/
|
*/
|
||||||
@Post('/config/toggle', { middlewares: [samlLicensedMiddleware] })
|
@Post('/config/toggle', { middlewares: [samlLicensedMiddleware] })
|
||||||
@GlobalScope('saml:manage')
|
@GlobalScope('saml:manage')
|
||||||
async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) {
|
async toggleEnabledPost(
|
||||||
if (req.body.loginEnabled === undefined) {
|
_req: AuthenticatedRequest,
|
||||||
throw new BadRequestError('Body should contain a boolean "loginEnabled" property');
|
res: Response,
|
||||||
}
|
@Body { loginEnabled }: SamlToggleDto,
|
||||||
await this.samlService.setSamlPreferences({ loginEnabled: req.body.loginEnabled });
|
) {
|
||||||
|
await this.samlService.setSamlPreferences({ loginEnabled });
|
||||||
return res.sendStatus(200);
|
return res.sendStatus(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +82,7 @@ export class SamlController {
|
||||||
* Assertion Consumer Service endpoint
|
* Assertion Consumer Service endpoint
|
||||||
*/
|
*/
|
||||||
@Get('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
|
@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');
|
return await this.acsHandler(req, res, 'redirect');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,8 +90,8 @@ export class SamlController {
|
||||||
* Assertion Consumer Service endpoint
|
* Assertion Consumer Service endpoint
|
||||||
*/
|
*/
|
||||||
@Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
|
@Post('/acs', { middlewares: [samlLicensedMiddleware], skipAuth: true, usesTemplates: true })
|
||||||
async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) {
|
async acsPost(req: AuthlessRequest, res: Response, @Body payload: SamlAcsDto) {
|
||||||
return await this.acsHandler(req, res, 'post');
|
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
|
* For test connections, returns status 202 if SAML is not enabled
|
||||||
*/
|
*/
|
||||||
private async acsHandler(
|
private async acsHandler(
|
||||||
req: SamlConfiguration.AcsRequest,
|
req: AuthlessRequest,
|
||||||
res: express.Response,
|
res: Response,
|
||||||
binding: SamlLoginBinding,
|
binding: SamlLoginBinding,
|
||||||
|
payload: SamlAcsDto = {},
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const loginResult = await this.samlService.handleSamlLogin(req, binding);
|
const loginResult = await this.samlService.handleSamlLogin(req, binding);
|
||||||
// if RelayState is set to the test connection Url, this is a test connection
|
// if RelayState is set to the test connection Url, this is a test connection
|
||||||
if (isConnectionTestRequest(req)) {
|
if (isConnectionTestRequest(payload)) {
|
||||||
if (loginResult.authenticatedUser) {
|
if (loginResult.authenticatedUser) {
|
||||||
return res.render('saml-connection-test-success', loginResult.attributes);
|
return res.render('saml-connection-test-success', loginResult.attributes);
|
||||||
} else {
|
} else {
|
||||||
|
@ -139,7 +130,7 @@ export class SamlController {
|
||||||
if (loginResult.onboardingRequired) {
|
if (loginResult.onboardingRequired) {
|
||||||
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
|
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
|
||||||
} else {
|
} else {
|
||||||
const redirectUrl = req.body?.RelayState ?? '/';
|
const redirectUrl = payload.RelayState ?? '/';
|
||||||
return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl);
|
return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -153,7 +144,7 @@ export class SamlController {
|
||||||
// Need to manually send the error response since we're using templates
|
// Need to manually send the error response since we're using templates
|
||||||
return sendErrorResponse(res, new AuthError('SAML Authentication failed'));
|
return sendErrorResponse(res, new AuthError('SAML Authentication failed'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isConnectionTestRequest(req)) {
|
if (isConnectionTestRequest(payload)) {
|
||||||
return res.render('saml-connection-test-failed', { message: (error as Error).message });
|
return res.render('saml-connection-test-failed', { message: (error as Error).message });
|
||||||
}
|
}
|
||||||
this.eventService.emit('user-login-failed', {
|
this.eventService.emit('user-login-failed', {
|
||||||
|
@ -173,7 +164,7 @@ export class SamlController {
|
||||||
* This endpoint is available if SAML is licensed and enabled
|
* This endpoint is available if SAML is licensed and enabled
|
||||||
*/
|
*/
|
||||||
@Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true })
|
@Get('/initsso', { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true })
|
||||||
async initSsoGet(req: express.Request, res: express.Response) {
|
async initSsoGet(req: AuthlessRequest, res: Response) {
|
||||||
let redirectUrl = '';
|
let redirectUrl = '';
|
||||||
try {
|
try {
|
||||||
const refererUrl = req.headers.referer;
|
const refererUrl = req.headers.referer;
|
||||||
|
@ -198,11 +189,11 @@ export class SamlController {
|
||||||
*/
|
*/
|
||||||
@Get('/config/test', { middlewares: [samlLicensedMiddleware] })
|
@Get('/config/test', { middlewares: [samlLicensedMiddleware] })
|
||||||
@GlobalScope('saml:manage')
|
@GlobalScope('saml:manage')
|
||||||
async configTestGet(_: AuthenticatedRequest, res: express.Response) {
|
async configTestGet(_: AuthenticatedRequest, res: Response) {
|
||||||
return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl());
|
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);
|
const result = await this.samlService.getLoginRequestUrl(relayState);
|
||||||
if (result?.binding === 'redirect') {
|
if (result?.binding === 'redirect') {
|
||||||
return result.context.context;
|
return result.context.context;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types';
|
||||||
import { randomString } from 'n8n-workflow';
|
import { randomString } from 'n8n-workflow';
|
||||||
import type { FlowResult } from 'samlify/types/src/flow';
|
import type { FlowResult } from 'samlify/types/src/flow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
@ -14,10 +15,7 @@ import { PasswordUtility } from '@/services/password.utility';
|
||||||
|
|
||||||
import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
|
import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
|
||||||
import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee';
|
import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee';
|
||||||
import type { SamlConfiguration } from './types/requests';
|
import type { SamlAttributeMapping, SamlUserAttributes } from './types';
|
||||||
import type { SamlAttributeMapping } from './types/saml-attribute-mapping';
|
|
||||||
import type { SamlPreferences } from './types/saml-preferences';
|
|
||||||
import type { SamlUserAttributes } from './types/saml-user-attributes';
|
|
||||||
import {
|
import {
|
||||||
getCurrentAuthenticationMethod,
|
getCurrentAuthenticationMethod,
|
||||||
isEmailCurrentAuthenticationMethod,
|
isEmailCurrentAuthenticationMethod,
|
||||||
|
@ -165,6 +163,6 @@ export function getMappedSamlAttributesFromFlowResult(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean {
|
export function isConnectionTestRequest(payload: SamlAcsDto): boolean {
|
||||||
return req.body.RelayState === getServiceProviderConfigTestReturnUrl();
|
return payload.RelayState === getServiceProviderConfigTestReturnUrl();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,115 +1,87 @@
|
||||||
import { Logger } from 'n8n-core';
|
import { Logger } from 'n8n-core';
|
||||||
import { Container } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import type { XMLFileInfo } from 'xmllint-wasm';
|
import type { XMLFileInfo, XMLLintOptions, XMLValidationResult } from 'xmllint-wasm';
|
||||||
|
|
||||||
let xmlMetadata: XMLFileInfo;
|
@Service()
|
||||||
let xmlProtocol: XMLFileInfo;
|
export class SamlValidator {
|
||||||
|
private xmlMetadata: XMLFileInfo;
|
||||||
|
|
||||||
let preload: XMLFileInfo[] = [];
|
private xmlProtocol: XMLFileInfo;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
private preload: XMLFileInfo[] = [];
|
||||||
let xmllintWasm: typeof import('xmllint-wasm') | undefined;
|
|
||||||
|
|
||||||
// dynamically load schema files
|
constructor(private readonly logger: Logger) {}
|
||||||
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'),
|
|
||||||
|
|
||||||
// WS-Federation
|
private xmllint: {
|
||||||
import('./schema/ws-federation.xsd'),
|
validateXML: (options: XMLLintOptions) => Promise<XMLValidationResult>;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// dynamically load xmllint-wasm
|
async init() {
|
||||||
async function loadXmllintWasm(): Promise<void> {
|
await this.loadSchemas();
|
||||||
if (xmllintWasm === undefined) {
|
this.xmllint = await import('xmllint-wasm');
|
||||||
Container.get(Logger).debug('Loading xmllint-wasm library into memory');
|
|
||||||
xmllintWasm = await import('xmllint-wasm');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateMetadata(metadata: string): Promise<boolean> {
|
async validateMetadata(metadata: string): Promise<boolean> {
|
||||||
const logger = Container.get(Logger);
|
return await this.validateXml('metadata', metadata);
|
||||||
try {
|
}
|
||||||
await loadXmllintWasm();
|
|
||||||
await loadSchemas();
|
async validateResponse(response: string): Promise<boolean> {
|
||||||
const validationResult = await xmllintWasm?.validateXML({
|
return await this.validateXml('response', response);
|
||||||
xml: [
|
}
|
||||||
{
|
|
||||||
fileName: 'metadata.xml',
|
// dynamically load schema files
|
||||||
contents: metadata,
|
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;
|
||||||
extension: 'schema',
|
this.preload = (
|
||||||
schema: [xmlMetadata],
|
await Promise.all([
|
||||||
preload: [xmlProtocol, ...preload],
|
// SAML
|
||||||
});
|
import('./schema/saml-schema-assertion-2.0.xsd'),
|
||||||
if (validationResult?.valid) {
|
import('./schema/xmldsig-core-schema.xsd'),
|
||||||
logger.debug('SAML Metadata is valid');
|
import('./schema/xenc-schema.xsd'),
|
||||||
return true;
|
import('./schema/xml.xsd'),
|
||||||
} else {
|
|
||||||
logger.warn('SAML Validate Metadata: Invalid metadata');
|
// WS-Federation
|
||||||
logger.warn(
|
import('./schema/ws-federation.xsd'),
|
||||||
validationResult
|
import('./schema/oasis-200401-wss-wssecurity-secext-1.0.xsd'),
|
||||||
? validationResult.errors
|
import('./schema/oasis-200401-wss-wssecurity-utility-1.0.xsd'),
|
||||||
.map((error) => `${error.message} - ${error.rawMessage}`)
|
import('./schema/ws-addr.xsd'),
|
||||||
.join('\n')
|
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) {
|
return false;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
logger.warn(error);
|
|
||||||
}
|
}
|
||||||
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 axios from 'axios';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
|
@ -5,7 +6,7 @@ import { Logger } from 'n8n-core';
|
||||||
import { ApplicationError, jsonParse } from 'n8n-workflow';
|
import { ApplicationError, jsonParse } from 'n8n-workflow';
|
||||||
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
||||||
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
|
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 { Settings } from '@/databases/entities/settings';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
@ -27,11 +28,9 @@ import {
|
||||||
setSamlLoginLabel,
|
setSamlLoginLabel,
|
||||||
updateUserFromSamlAttributes,
|
updateUserFromSamlAttributes,
|
||||||
} from './saml-helpers';
|
} from './saml-helpers';
|
||||||
import { validateMetadata, validateResponse } from './saml-validator';
|
import { SamlValidator } from './saml-validator';
|
||||||
import { getServiceProviderInstance } from './service-provider.ee';
|
import { getServiceProviderInstance } from './service-provider.ee';
|
||||||
import type { SamlLoginBinding } from './types';
|
import type { SamlLoginBinding, SamlUserAttributes } from './types';
|
||||||
import type { SamlPreferences } from './types/saml-preferences';
|
|
||||||
import type { SamlUserAttributes } from './types/saml-user-attributes';
|
|
||||||
import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers';
|
import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
|
@ -79,12 +78,16 @@ export class SamlService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
|
private readonly validator: SamlValidator,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly settingsRepository: SettingsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// load preferences first but do not apply so as to not load samlify unnecessarily
|
// load preferences first but do not apply so as to not load samlify unnecessarily
|
||||||
await this.loadFromDbAndApplySamlPreferences(false);
|
await this.loadFromDbAndApplySamlPreferences(false);
|
||||||
|
await this.validator.init();
|
||||||
if (isSamlLicensedAndEnabled()) {
|
if (isSamlLicensedAndEnabled()) {
|
||||||
await this.loadSamlify();
|
await this.loadSamlify();
|
||||||
await this.loadFromDbAndApplySamlPreferences(true);
|
await this.loadFromDbAndApplySamlPreferences(true);
|
||||||
|
@ -108,9 +111,10 @@ export class SamlService {
|
||||||
this.logger.debug('Loading samlify library into memory');
|
this.logger.debug('Loading samlify library into memory');
|
||||||
this.samlify = await import('samlify');
|
this.samlify = await import('samlify');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.samlify.setSchemaValidator({
|
this.samlify.setSchemaValidator({
|
||||||
validate: async (response: string) => {
|
validate: async (response: string) => {
|
||||||
const valid = await validateResponse(response);
|
const valid = await this.validator.validateResponse(response);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new InvalidSamlMetadataError();
|
throw new InvalidSamlMetadataError();
|
||||||
}
|
}
|
||||||
|
@ -188,7 +192,7 @@ export class SamlService {
|
||||||
const attributes = await this.getAttributesFromLoginResponse(req, binding);
|
const attributes = await this.getAttributesFromLoginResponse(req, binding);
|
||||||
if (attributes.email) {
|
if (attributes.email) {
|
||||||
const lowerCasedEmail = attributes.email.toLowerCase();
|
const lowerCasedEmail = attributes.email.toLowerCase();
|
||||||
const user = await Container.get(UserRepository).findOne({
|
const user = await this.userRepository.findOne({
|
||||||
where: { email: lowerCasedEmail },
|
where: { email: lowerCasedEmail },
|
||||||
relations: ['authIdentities'],
|
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.loadSamlify();
|
||||||
await this.loadPreferencesWithoutValidation(prefs);
|
await this.loadPreferencesWithoutValidation(prefs);
|
||||||
if (prefs.metadataUrl) {
|
if (prefs.metadataUrl) {
|
||||||
|
@ -242,7 +246,7 @@ export class SamlService {
|
||||||
this._samlPreferences.metadata = fetchedMetadata;
|
this._samlPreferences.metadata = fetchedMetadata;
|
||||||
}
|
}
|
||||||
} else if (prefs.metadata) {
|
} else if (prefs.metadata) {
|
||||||
const validationResult = await validateMetadata(prefs.metadata);
|
const validationResult = await this.validator.validateMetadata(prefs.metadata);
|
||||||
if (!validationResult) {
|
if (!validationResult) {
|
||||||
throw new InvalidSamlMetadataError();
|
throw new InvalidSamlMetadataError();
|
||||||
}
|
}
|
||||||
|
@ -252,7 +256,7 @@ export class SamlService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPreferencesWithoutValidation(prefs: SamlPreferences) {
|
async loadPreferencesWithoutValidation(prefs: Partial<SamlPreferences>) {
|
||||||
this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
|
this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
|
||||||
this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
|
this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
|
||||||
this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
|
this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
|
||||||
|
@ -278,7 +282,7 @@ export class SamlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> {
|
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 },
|
where: { key: SAML_PREFERENCES_DB_KEY },
|
||||||
});
|
});
|
||||||
if (samlPreferences) {
|
if (samlPreferences) {
|
||||||
|
@ -296,18 +300,18 @@ export class SamlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
|
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
|
||||||
const samlPreferences = await Container.get(SettingsRepository).findOne({
|
const samlPreferences = await this.settingsRepository.findOne({
|
||||||
where: { key: SAML_PREFERENCES_DB_KEY },
|
where: { key: SAML_PREFERENCES_DB_KEY },
|
||||||
});
|
});
|
||||||
const settingsValue = JSON.stringify(this.samlPreferences);
|
const settingsValue = JSON.stringify(this.samlPreferences);
|
||||||
let result: Settings;
|
let result: Settings;
|
||||||
if (samlPreferences) {
|
if (samlPreferences) {
|
||||||
samlPreferences.value = settingsValue;
|
samlPreferences.value = settingsValue;
|
||||||
result = await Container.get(SettingsRepository).save(samlPreferences, {
|
result = await this.settingsRepository.save(samlPreferences, {
|
||||||
transaction: false,
|
transaction: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result = await Container.get(SettingsRepository).save(
|
result = await this.settingsRepository.save(
|
||||||
{
|
{
|
||||||
key: SAML_PREFERENCES_DB_KEY,
|
key: SAML_PREFERENCES_DB_KEY,
|
||||||
value: settingsValue,
|
value: settingsValue,
|
||||||
|
@ -332,7 +336,7 @@ export class SamlService {
|
||||||
const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent });
|
const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent });
|
||||||
if (response.status === 200 && response.data) {
|
if (response.status === 200 && response.data) {
|
||||||
const xml = (await response.data) as string;
|
const xml = (await response.data) as string;
|
||||||
const validationResult = await validateMetadata(xml);
|
const validationResult = await this.validator.validateMetadata(xml);
|
||||||
if (!validationResult) {
|
if (!validationResult) {
|
||||||
throw new BadRequestError(
|
throw new BadRequestError(
|
||||||
`Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`,
|
`Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`,
|
||||||
|
@ -392,6 +396,6 @@ export class SamlService {
|
||||||
*/
|
*/
|
||||||
async reset() {
|
async reset() {
|
||||||
await setSamlLoginEnabled(false);
|
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 type { ServiceProviderInstance } from 'samlify';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
import type { SamlPreferences } from './types/saml-preferences';
|
|
||||||
|
|
||||||
let serviceProviderInstance: ServiceProviderInstance | undefined;
|
let serviceProviderInstance: ServiceProviderInstance | undefined;
|
||||||
|
|
||||||
export function getServiceProviderEntityId(): string {
|
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 * 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 { getPersonalProject } from '../shared/db/projects';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
|
|
|
@ -34,6 +34,8 @@ beforeAll(async () => {
|
||||||
authMemberAgent = testServer.authAgentFor(someUser);
|
authMemberAgent = testServer.authAgentFor(someUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => await enableSaml(false));
|
||||||
|
|
||||||
describe('Instance owner', () => {
|
describe('Instance owner', () => {
|
||||||
describe('PATCH /me', () => {
|
describe('PATCH /me', () => {
|
||||||
test('should succeed with valid inputs', async () => {
|
test('should succeed with valid inputs', async () => {
|
||||||
|
@ -89,6 +91,17 @@ describe('Instance owner', () => {
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(getCurrentAuthenticationMethod()).toBe('saml');
|
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', () => {
|
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;
|
break;
|
||||||
|
|
||||||
case 'saml':
|
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');
|
await import('@/sso.ee/saml/routes/saml.controller.ee');
|
||||||
|
const { setSamlLoginEnabled } = await import('@/sso.ee/saml/saml-helpers');
|
||||||
await setSamlLoginEnabled(true);
|
await setSamlLoginEnabled(true);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
@ -56,7 +56,6 @@ import type {
|
||||||
ROLE,
|
ROLE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { BulkCommand, Undoable } from '@/models/history';
|
import type { BulkCommand, Undoable } from '@/models/history';
|
||||||
import type { PartialBy } from '@/utils/typeHelpers';
|
|
||||||
|
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
import type { ProjectSharingData } from '@/types/projects.types';
|
||||||
|
|
||||||
|
@ -1300,41 +1299,6 @@ export type ExecutionsQueryFilter = {
|
||||||
vote?: ExecutionFilterVote;
|
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 = {
|
export type SamlPreferencesExtractedData = {
|
||||||
entityID: string;
|
entityID: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
|
import type { SamlPreferences } from '@n8n/api-types';
|
||||||
import type { Server, Request } from 'miragejs';
|
import type { Server, Request } from 'miragejs';
|
||||||
import { Response } from 'miragejs';
|
import { Response } from 'miragejs';
|
||||||
import type { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface';
|
import type { SamlPreferencesExtractedData } from '@/Interface';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { AppSchema } from '@/__tests__/server/types';
|
import type { AppSchema } from '@/__tests__/server/types';
|
||||||
import { jsonParse } from 'n8n-workflow';
|
import { jsonParse } from 'n8n-workflow';
|
||||||
|
|
||||||
let samlConfig: SamlPreferences & SamlPreferencesExtractedData = {
|
let samlConfig = {
|
||||||
metadata: '<?xml version="1.0"?>',
|
metadata: '<?xml version="1.0"?>',
|
||||||
metadataUrl: '',
|
metadataUrl: '',
|
||||||
entityID: faker.internet.url(),
|
entityID: faker.internet.url(),
|
||||||
returnUrl: faker.internet.url(),
|
returnUrl: faker.internet.url(),
|
||||||
};
|
} as SamlPreferences & SamlPreferencesExtractedData;
|
||||||
|
|
||||||
export function routesForSSO(server: Server) {
|
export function routesForSSO(server: Server) {
|
||||||
server.get('/rest/sso/saml/config', () => {
|
server.get('/rest/sso/saml/config', () => {
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
|
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
import type {
|
import type { IRestApiContext, SamlPreferencesExtractedData } from '@/Interface';
|
||||||
IRestApiContext,
|
|
||||||
SamlPreferencesLoginEnabled,
|
|
||||||
SamlPreferences,
|
|
||||||
SamlPreferencesExtractedData,
|
|
||||||
} from '@/Interface';
|
|
||||||
|
|
||||||
export const initSSO = async (context: IRestApiContext): Promise<string> => {
|
export const initSSO = async (context: IRestApiContext): Promise<string> => {
|
||||||
return await makeRestApiRequest(context, 'GET', '/sso/saml/initsso');
|
return await makeRestApiRequest(context, 'GET', '/sso/saml/initsso');
|
||||||
|
@ -22,14 +18,14 @@ export const getSamlConfig = async (
|
||||||
|
|
||||||
export const saveSamlConfig = async (
|
export const saveSamlConfig = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
data: SamlPreferences,
|
data: Partial<SamlPreferences>,
|
||||||
): Promise<SamlPreferences | undefined> => {
|
): Promise<SamlPreferences | undefined> => {
|
||||||
return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data);
|
return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleSamlConfig = async (
|
export const toggleSamlConfig = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
data: SamlPreferencesLoginEnabled,
|
data: SamlToggleDto,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
return await makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data);
|
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 { computed, reactive } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { EnterpriseEditionFeature } from '@/constants';
|
import { EnterpriseEditionFeature } from '@/constants';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import * as ssoApi from '@/api/sso';
|
import * as ssoApi from '@/api/sso';
|
||||||
import type { SamlPreferences, SamlPreferencesExtractedData } from '@/Interface';
|
import type { SamlPreferencesExtractedData } from '@/Interface';
|
||||||
import { updateCurrentUser } from '@/api/users';
|
import { updateCurrentUser } from '@/api/users';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
|
||||||
|
@ -64,7 +65,7 @@ export const useSSOStore = defineStore('sso', () => {
|
||||||
state.samlConfig = samlConfig;
|
state.samlConfig = samlConfig;
|
||||||
return samlConfig;
|
return samlConfig;
|
||||||
};
|
};
|
||||||
const saveSamlConfig = async (config: SamlPreferences) =>
|
const saveSamlConfig = async (config: Partial<SamlPreferences>) =>
|
||||||
await ssoApi.saveSamlConfig(rootStore.restApiContext, config);
|
await ssoApi.saveSamlConfig(rootStore.restApiContext, config);
|
||||||
const testSamlConfig = async () => await ssoApi.testSamlConfig(rootStore.restApiContext);
|
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 { createTestingPinia } from '@pinia/testing';
|
||||||
import { within, waitFor } from '@testing-library/vue';
|
import { within, waitFor } from '@testing-library/vue';
|
||||||
import { mockedStore, retry } from '@/__tests__/utils';
|
import { mockedStore, retry } from '@/__tests__/utils';
|
||||||
|
@ -11,6 +12,7 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { EnterpriseEditionFeature } from '@/constants';
|
import { EnterpriseEditionFeature } from '@/constants';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import type { SamlPreferencesExtractedData } from '@/Interface';
|
||||||
|
|
||||||
const renderView = createComponentRenderer(SettingsSso);
|
const renderView = createComponentRenderer(SettingsSso);
|
||||||
|
|
||||||
|
@ -20,7 +22,7 @@ const samlConfig = {
|
||||||
'https://dev-qqkrykgkoo0p63d5.eu.auth0.com/samlp/metadata/KR1cSrRrxaZT2gV8ZhPAUIUHtEY4duhN',
|
'https://dev-qqkrykgkoo0p63d5.eu.auth0.com/samlp/metadata/KR1cSrRrxaZT2gV8ZhPAUIUHtEY4duhN',
|
||||||
entityID: 'https://n8n-tunnel.myhost.com/rest/sso/saml/metadata',
|
entityID: 'https://n8n-tunnel.myhost.com/rest/sso/saml/metadata',
|
||||||
returnUrl: 'https://n8n-tunnel.myhost.com/rest/sso/saml/acs',
|
returnUrl: 'https://n8n-tunnel.myhost.com/rest/sso/saml/acs',
|
||||||
};
|
} as SamlPreferences & SamlPreferencesExtractedData;
|
||||||
|
|
||||||
const telemetryTrack = vi.fn();
|
const telemetryTrack = vi.fn();
|
||||||
vi.mock('@/composables/useTelemetry', () => ({
|
vi.mock('@/composables/useTelemetry', () => ({
|
||||||
|
@ -133,7 +135,7 @@ describe('SettingsSso View', () => {
|
||||||
const urlinput = getByTestId('sso-provider-url');
|
const urlinput = getByTestId('sso-provider-url');
|
||||||
|
|
||||||
expect(urlinput).toBeVisible();
|
expect(urlinput).toBeVisible();
|
||||||
await userEvent.type(urlinput, samlConfig.metadataUrl);
|
await userEvent.type(urlinput, samlConfig.metadataUrl!);
|
||||||
|
|
||||||
expect(saveButton).not.toBeDisabled();
|
expect(saveButton).not.toBeDisabled();
|
||||||
await userEvent.click(saveButton);
|
await userEvent.click(saveButton);
|
||||||
|
@ -172,7 +174,7 @@ describe('SettingsSso View', () => {
|
||||||
const xmlInput = getByTestId('sso-provider-xml');
|
const xmlInput = getByTestId('sso-provider-xml');
|
||||||
|
|
||||||
expect(xmlInput).toBeVisible();
|
expect(xmlInput).toBeVisible();
|
||||||
await userEvent.type(xmlInput, samlConfig.metadata);
|
await userEvent.type(xmlInput, samlConfig.metadata!);
|
||||||
|
|
||||||
expect(saveButton).not.toBeDisabled();
|
expect(saveButton).not.toBeDisabled();
|
||||||
await userEvent.click(saveButton);
|
await userEvent.click(saveButton);
|
||||||
|
|
Loading…
Reference in a new issue