From 552cff18609494eea3806d797b2faf7061aa0eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Fri, 3 Jan 2025 11:05:30 +0100 Subject: [PATCH] refactor(core): Extract SAML requests payloads into DTOs (#12435) --- packages/@n8n/api-types/src/dto/index.ts | 4 + .../__tests__/saml-preferences.dto.test.ts | 155 +++++++++++++++ .../api-types/src/dto/saml/saml-acs.dto.ts | 6 + .../src/dto/saml/saml-preferences.dto.ts | 50 +++++ .../api-types/src/dto/saml/saml-toggle.dto.ts | 6 + .../saml/__tests__/saml-helpers.test.ts | 2 +- .../saml/__tests__/saml-validator.test.ts | 152 +++++++++++++-- .../saml/__tests__/saml.service.ee.test.ts | 6 +- .../__tests__/saml.controller.ee.test.ts | 29 ++- .../sso.ee/saml/routes/saml.controller.ee.ts | 59 +++--- packages/cli/src/sso.ee/saml/saml-helpers.ts | 10 +- .../cli/src/sso.ee/saml/saml-validator.ts | 178 ++++++++---------- .../cli/src/sso.ee/saml/saml.service.ee.ts | 36 ++-- .../src/sso.ee/saml/service-provider.ee.ts | 3 +- packages/cli/src/sso.ee/saml/types.ts | 5 + packages/cli/src/sso.ee/saml/types/index.ts | 1 - .../cli/src/sso.ee/saml/types/requests.ts | 17 -- .../saml/types/saml-attribute-mapping.ts | 6 - .../src/sso.ee/saml/types/saml-preferences.ts | 65 ------- .../sso.ee/saml/types/saml-user-attributes.ts | 6 - .../integration/saml/saml-helpers.test.ts | 2 +- .../test/integration/saml/saml.api.test.ts | 13 ++ .../test/integration/saml/sample-metadata.ts | 6 +- .../integration/shared/utils/test-server.ts | 4 +- packages/editor-ui/src/Interface.ts | 36 ---- .../src/__tests__/server/endpoints/sso.ts | 7 +- packages/editor-ui/src/api/sso.ts | 12 +- packages/editor-ui/src/stores/sso.store.ts | 5 +- .../editor-ui/src/views/SettingsSso.test.ts | 8 +- 29 files changed, 535 insertions(+), 354 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts create mode 100644 packages/cli/src/sso.ee/saml/types.ts delete mode 100644 packages/cli/src/sso.ee/saml/types/index.ts delete mode 100644 packages/cli/src/sso.ee/saml/types/requests.ts delete mode 100644 packages/cli/src/sso.ee/saml/types/saml-attribute-mapping.ts delete mode 100644 packages/cli/src/sso.ee/saml/types/saml-preferences.ts delete mode 100644 packages/cli/src/sso.ee/saml/types/saml-user-attributes.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index ea725e2ec5..6dbfe17361 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts new file mode 100644 index 0000000000..6d11483347 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts @@ -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: 'metadata', + 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: 'metadata', + 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(''); + }); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts new file mode 100644 index 0000000000..2bfbece7d6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-acs.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class SamlAcsDto extends Z.class({ + RelayState: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts new file mode 100644 index 0000000000..e07504c1b3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts @@ -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(''), +}) {} diff --git a/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts new file mode 100644 index 0000000000..be07933d06 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/saml/saml-toggle.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class SamlToggleDto extends Z.class({ + loginEnabled: z.boolean(), +}) {} diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts index f544e050ed..d75fdc8a7f 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts @@ -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); diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts index 563c7934ea..9f93550bf2 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-validator.test.ts @@ -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== - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -43,8 +46,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -169,8 +171,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -194,8 +195,7 @@ describe('saml-validator', () => { - - MIIC8DCCAdigAwIBAgIQf+iroClVKohAtsyk0Ne13TANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNDExMTMxMDEwNTNaFw0yNzExMTMxMDEwNTNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE8Ad1OMQKfaHi6YrsEcmMNwIAQ86h7JmnuABf5xLNd27jaMF4FVxHbEtC/BYxtcmwld5zbkCVXQ6PT6VoeYIjHMVnptFXg15EGgjnqpxWsjLDQNoSdSQu8VhG+8Yb5M7KPt+UEZfsRZVrgqMjdSEMVrOzPMD8KMB7wnghYX6npcZhn7D5w/F9gVDpI1Um8M/FIUKYVSYFjky1i24WvKmcBf71mAacZp48Zuj5by/ELIb6gAjpW5xpd02smpLthy/Yo4XDIQQurFOfjqyZd8xAZu/SfPsbjtymWw59tgd9RdYISl6O/241kY9h6Ojtx6WShOVDi6q+bJrfj9Z8WKcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCiVxiQ9KpjihliQzIW45YO0EvRJtoPtyVAh9RiSGozbTl4otfrUJf8nbRtj7iZBRuuW4rrtRAH5kDb+i1wNUUQED2Pl/l4x5cN0oBytP3GSymq6NJx1gUOBO1BrNY+c3r5yHOUyj5qpbw9UkqpG1AqQkLLeZqB/yVCyOBQT7SKTbXVYhGefFM/+6z0/rGsWZN5OF6/2NC06ws1v4In28Atgpg4XxFh5TL7rPMJ11ca5MN9lHJoIUsvls053eQBcd7vJneqzd904B6WtPld6KOJK4dzIt9edHzPhaz158awWwx3iHsMn1Y/T0WVy5/4ZTzxY/i4U3t1Yt8ktxewVJYT + ${VALID_CERTIFICATE} @@ -209,7 +209,7 @@ describe('saml-validator', () => { `; // ACT - const result = await validateMetadata(metadata); + const result = await validator.validateMetadata(metadata); // ASSERT expect(result).toBe(true); @@ -225,7 +225,85 @@ describe('saml-validator', () => { `; // 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 = ` + + + + + + ${VALID_CERTIFICATE} + + + + + + `; // Missing closing tags + + // ACT + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects metadata missing SingleSignOnService', async () => { + // ARRANGE + const metadata = ` + + + + + + ${VALID_CERTIFICATE} + + + + + + `; + + // ACT + const result = await validator.validateMetadata(metadata); + + // ASSERT + expect(result).toBe(false); + }); + + test('rejects metadata with invalid X.509 certificate', async () => { + // ARRANGE + const metadata = ` + + + + + + + INVALID_CERTIFICATE + + + + + + + `; + + // ACT + const result = await validator.validateMetadata(metadata); // ASSERT expect(result).toBe(false); @@ -328,13 +406,13 @@ describe('saml-validator', () => { `; // 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 = ` { `; // 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 = ` + + https://sts.windows.net/random-issuer/ + + + + + https://sts.windows.net/random-issuer/ + + + random_name_id + + + + + // Expired + + http://localhost:5678/rest/sso/saml/metadata + + + + `; + + // ACT + const result = await validator.validateResponse(response); // ASSERT expect(result).toBe(false); diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts index 6070104571..ebf34e3075 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml.service.ee.test.ts @@ -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(); diff --git a/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts index c4a33ed441..928f6d6df0 100644 --- a/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts @@ -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(); const controller = new SamlController(mock(), samlService, mock(), mock()); const user = mock({ @@ -31,46 +27,45 @@ const attributes: SamlUserAttributes = { }; describe('Test views', () => { + const RelayState = getServiceProviderConfigTestReturnUrl(); + test('Should render success with template', async () => { - const req = mock(); + const req = mock(); const res = mock(); - 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(); + const req = mock(); const res = mock(); - 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(); + const req = mock(); const res = mock(); - 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' }); }); diff --git a/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts index c7b954914b..c8f636eec4 100644 --- a/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts @@ -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; diff --git a/packages/cli/src/sso.ee/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts index 996e17b359..8479314d4b 100644 --- a/packages/cli/src/sso.ee/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -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(); } diff --git a/packages/cli/src/sso.ee/saml/saml-validator.ts b/packages/cli/src/sso.ee/saml/saml-validator.ts index 582fe624a5..570b279c72 100644 --- a/packages/cli/src/sso.ee/saml/saml-validator.ts +++ b/packages/cli/src/sso.ee/saml/saml-validator.ts @@ -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 { - 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; + }; -// dynamically load xmllint-wasm -async function loadXmllintWasm(): Promise { - 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 { - 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 { + return await this.validateXml('metadata', metadata); + } + + async validateResponse(response: string): Promise { + return await this.validateXml('response', response); + } + + // dynamically load schema files + private async loadSchemas(): Promise { + 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 { + 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 { - 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; } diff --git a/packages/cli/src/sso.ee/saml/saml.service.ee.ts b/packages/cli/src/sso.ee/saml/saml.service.ee.ts index 2944d9adf1..fd03471b67 100644 --- a/packages/cli/src/sso.ee/saml/saml.service.ee.ts +++ b/packages/cli/src/sso.ee/saml/saml.service.ee.ts @@ -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 { 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 { + async setSamlPreferences(prefs: Partial): Promise { 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) { 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 { - 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 { - 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 }); } } diff --git a/packages/cli/src/sso.ee/saml/service-provider.ee.ts b/packages/cli/src/sso.ee/saml/service-provider.ee.ts index 2e6511df09..0522c80b51 100644 --- a/packages/cli/src/sso.ee/saml/service-provider.ee.ts +++ b/packages/cli/src/sso.ee/saml/service-provider.ee.ts @@ -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 { diff --git a/packages/cli/src/sso.ee/saml/types.ts b/packages/cli/src/sso.ee/saml/types.ts new file mode 100644 index 0000000000..35687777b1 --- /dev/null +++ b/packages/cli/src/sso.ee/saml/types.ts @@ -0,0 +1,5 @@ +import type { SamlPreferences } from '@n8n/api-types'; + +export type SamlLoginBinding = SamlPreferences['loginBinding']; +export type SamlAttributeMapping = NonNullable; +export type SamlUserAttributes = SamlAttributeMapping; diff --git a/packages/cli/src/sso.ee/saml/types/index.ts b/packages/cli/src/sso.ee/saml/types/index.ts deleted file mode 100644 index 560f7003f8..0000000000 --- a/packages/cli/src/sso.ee/saml/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type SamlLoginBinding = 'post' | 'redirect'; diff --git a/packages/cli/src/sso.ee/saml/types/requests.ts b/packages/cli/src/sso.ee/saml/types/requests.ts deleted file mode 100644 index 69fb89a1eb..0000000000 --- a/packages/cli/src/sso.ee/saml/types/requests.ts +++ /dev/null @@ -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; - }, - {} - >; -} diff --git a/packages/cli/src/sso.ee/saml/types/saml-attribute-mapping.ts b/packages/cli/src/sso.ee/saml/types/saml-attribute-mapping.ts deleted file mode 100644 index af7dd76e23..0000000000 --- a/packages/cli/src/sso.ee/saml/types/saml-attribute-mapping.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SamlAttributeMapping { - email: string; - firstName: string; - lastName: string; - userPrincipalName: string; -} diff --git a/packages/cli/src/sso.ee/saml/types/saml-preferences.ts b/packages/cli/src/sso.ee/saml/types/saml-preferences.ts deleted file mode 100644 index 1231684360..0000000000 --- a/packages/cli/src/sso.ee/saml/types/saml-preferences.ts +++ /dev/null @@ -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 = ''; -} diff --git a/packages/cli/src/sso.ee/saml/types/saml-user-attributes.ts b/packages/cli/src/sso.ee/saml/types/saml-user-attributes.ts deleted file mode 100644 index fa3c849f65..0000000000 --- a/packages/cli/src/sso.ee/saml/types/saml-user-attributes.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SamlUserAttributes { - email: string; - firstName: string; - lastName: string; - userPrincipalName: string; -} diff --git a/packages/cli/test/integration/saml/saml-helpers.test.ts b/packages/cli/test/integration/saml/saml-helpers.test.ts index 3396c0edc7..87d020248c 100644 --- a/packages/cli/test/integration/saml/saml-helpers.test.ts +++ b/packages/cli/test/integration/saml/saml-helpers.test.ts @@ -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'; diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index 247faaacba..7737444c6b 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -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', () => { diff --git a/packages/cli/test/integration/saml/sample-metadata.ts b/packages/cli/test/integration/saml/sample-metadata.ts index fd7968c2fb..528a3f158f 100644 --- a/packages/cli/test/integration/saml/sample-metadata.ts +++ b/packages/cli/test/integration/saml/sample-metadata.ts @@ -1,7 +1,8 @@ +import type { SamlPreferences } from '@n8n/api-types'; export const sampleMetadata = '\n\n\n\n\n\n\n\n\n\n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\n7EzvuRaQc7pNrduO1pTKoPAy+2SLgqo=\n\n\nMIIFUzCCAzugAwIBAgIRAJ1peD6pO0pygujUcWb85QswDQYJKoZIhvcNAQELBQAwHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjMuMi4yMB4XDTIzMDIyNzEzMTQ0MFoXDTI0MDIyODEzMTQ0MFowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3thve9UWPL09ouGwUPlCxfrBDDKmDdvoMc3eahfuop2tSP38EvdBcnPCVYTtu2hhHNqN/QtoyAZcTvwD8oDjwiYxdO6VbNjMZAnMD4W84l2niGnG7ATy/niNcZoge4xy+OmCJKXsolbsXT+hQGQ2oiUDnbX8QwMQCMN8FBF+EvYoHXKvRjmjO75DHyHY9JP05HZTO3lycVLWGrIq4oJfp60PN/0z5tbpk/Tyst21o4lcESAM4fkmndonPmoKMr7q9g+CFYRT+As6iB+L38J44YNWs0Qm42tHAlveinBRuLLMi+eMC2L0sckvyJKB1qHG+bKl7jVXNDJg5KWKEHdM4CBg3dJkign+12EO205ruLYSBydZErAb2NKd2htgYs/zGHSgb3LhQ3vEuHiTIcq828PWmVM7l3B8CJ+ZyPLixywT0pKgkb8lrDqzXIffRljCYMT2pIR4FNuy+CzXMYm+N30qVO8h9+cl3YRSHpFBk9KJ0/+HQp1k6ELnaYW+LryS8Jr1uPxhwyMqGu+4bxCF8JfZncojMhlQghXCQUvOaboNlBWv5jtsoZ9mN266V1EJpnF064UimQ1foN1O4l4292NvkChcmiQf2YDE5PrMWm10gQg401oulE9o91OsxLRmyw/qZTJvA06KgVamNLfhN/St/CVfl8q6ldgoHmWaxY8CAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDT1BRVVpWNW1qdWFvQ01hdEVvenU5ajNoUnlhU0UyQThaTjd4WlZqUy5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAwaQtK4s2DnJxjg6i6BSo/rhNg7ClXgnOyF79T7JO3gexVjzboY2UTi1ut/DEII01PI0qgQ62+q9lTloWd1SpxPOrOVeu2uVgTK0LkGb63q355iJ2myfhFYYPPprNDzvUhnX8cVY979MaiqAOCJW7irlHAH2bLAujanRdlcgFtmoe5lZ+qnS5iOUmp5tehPsDJGlPZ3nCWJcRQHDLLSOp3TvR5no8nj0cWxUWnNeaGoJy1GsJlGapLXS5pUKpxVg9GeEcQxjBkFgMLWrkWBsQDvC5+GlmHgSkdRvuYBlB6CRK2eGY7G06v7ZRPhf82LvEFRBwzJvGdM0g491OTTJquTN2wyq45UlJK4anMYrUbpi8p8MOW7IUw6a+SvZyJab9gNoLTUzA6MlzQP9bPrEALpwNhmHsmD09zNyYiNfpkpLJog96wPscx4b+gsg+5PcilET8qvth6VYDup8TdsonPvDPH0oyo66SAYoyOgAeB+BHTicjtVt+UnrhXYj92BHDXfmfdTzA8QcY7reLPIOQVk1zV24cwySiLh4F2Hr8z8V1wMRVNVHcezMsVBvCzxQ15XlMq9X2wBujfED93dXJVs+WuzbpTIoXvHHT3zWnzykX8hVbrj9ddzF8TuJW4NYis0cH5SLzvtPj7EzvuRaQc7pNrduO1pTKoPAy+2SLgqo=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectNameurn:oasis:names:tc:SAML:2.0:nameid-format:transient'; -export const sampleConfig = { +export const sampleConfig: SamlPreferences = { mapping: { email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', @@ -25,6 +26,5 @@ export const sampleConfig = { action: 'after', }, }, - entityID: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/metadata', - returnUrl: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/acs', + relayState: '', }; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 52e97f8f31..d4c3437728 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 22d2387548..2b898f7b44 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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; - export type SamlPreferencesExtractedData = { entityID: string; returnUrl: string; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/sso.ts b/packages/editor-ui/src/__tests__/server/endpoints/sso.ts index a9c13e7285..91b1aaaaf2 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/sso.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/sso.ts @@ -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: '', metadataUrl: '', entityID: faker.internet.url(), returnUrl: faker.internet.url(), -}; +} as SamlPreferences & SamlPreferencesExtractedData; export function routesForSSO(server: Server) { server.get('/rest/sso/saml/config', () => { diff --git a/packages/editor-ui/src/api/sso.ts b/packages/editor-ui/src/api/sso.ts index 95b0425e95..c990f19c06 100644 --- a/packages/editor-ui/src/api/sso.ts +++ b/packages/editor-ui/src/api/sso.ts @@ -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 => { 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, ): Promise => { return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data); }; export const toggleSamlConfig = async ( context: IRestApiContext, - data: SamlPreferencesLoginEnabled, + data: SamlToggleDto, ): Promise => { return await makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data); }; diff --git a/packages/editor-ui/src/stores/sso.store.ts b/packages/editor-ui/src/stores/sso.store.ts index d9d522274c..3df11b0e2e 100644 --- a/packages/editor-ui/src/stores/sso.store.ts +++ b/packages/editor-ui/src/stores/sso.store.ts @@ -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) => await ssoApi.saveSamlConfig(rootStore.restApiContext, config); const testSamlConfig = async () => await ssoApi.testSamlConfig(rootStore.restApiContext); diff --git a/packages/editor-ui/src/views/SettingsSso.test.ts b/packages/editor-ui/src/views/SettingsSso.test.ts index f42160f5ee..bbd927a1e3 100644 --- a/packages/editor-ui/src/views/SettingsSso.test.ts +++ b/packages/editor-ui/src/views/SettingsSso.test.ts @@ -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);