mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(API): Add endpoint to set the SAML SSO configuration (#34224)
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type { ZodArray, ZodDefault, ZodObject, ZodOptional, ZodRawShape, ZodTypeAny } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
type ZodObjectKeyTree = {
|
||||
[key: string]: ZodObjectKeyTree | true;
|
||||
};
|
||||
|
||||
const isZodOptional = (schema: ZodTypeAny): schema is ZodOptional<ZodTypeAny> => {
|
||||
return schema instanceof z.ZodOptional;
|
||||
};
|
||||
|
||||
const isZodDefault = (schema: ZodTypeAny): schema is ZodDefault<ZodTypeAny> => {
|
||||
return schema instanceof z.ZodDefault;
|
||||
};
|
||||
|
||||
const isZodObject = (schema: ZodTypeAny): schema is ZodObject<ZodRawShape> => {
|
||||
return schema instanceof z.ZodObject;
|
||||
};
|
||||
|
||||
const isZodArray = (schema: ZodTypeAny): schema is ZodArray<ZodTypeAny> => {
|
||||
return schema instanceof z.ZodArray;
|
||||
};
|
||||
|
||||
const unwrapOptionalOrDefault = (schema: ZodTypeAny): ZodTypeAny => {
|
||||
if (isZodOptional(schema) || isZodDefault(schema)) {
|
||||
return unwrapOptionalOrDefault(schema._def.innerType);
|
||||
}
|
||||
return schema;
|
||||
};
|
||||
|
||||
/** Nested ZodObject key tree; non-objects become `true` leaf markers. */
|
||||
const zodObjectKeyTree = (schema: ZodTypeAny): ZodObjectKeyTree | true => {
|
||||
const current = unwrapOptionalOrDefault(schema);
|
||||
if (!isZodObject(current)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tree: ZodObjectKeyTree = {};
|
||||
const shape: ZodRawShape = current.shape;
|
||||
for (const key of Object.keys(shape)) {
|
||||
const fieldSchema = shape[key];
|
||||
if (fieldSchema === undefined) {
|
||||
continue;
|
||||
}
|
||||
tree[key] = zodObjectKeyTree(fieldSchema);
|
||||
}
|
||||
return tree;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether two Zod schemas have the same nested object keys after stripping
|
||||
* `.optional()` / `.default()` wrappers. Useful to keep a full Public API PUT
|
||||
* body aligned with the internal preferences DTO.
|
||||
*/
|
||||
export const zodObjectKeysMatch = (a: ZodTypeAny, b: ZodTypeAny): boolean => {
|
||||
return isDeepStrictEqual(zodObjectKeyTree(a), zodObjectKeyTree(b));
|
||||
};
|
||||
|
||||
/** Whether a Zod object (and nested objects/array items) has no `.optional()` / `.default()` fields. */
|
||||
export const zodObjectFieldsAreAllRequired = (schema: ZodTypeAny): boolean => {
|
||||
if (isZodOptional(schema) || isZodDefault(schema)) {
|
||||
return false;
|
||||
}
|
||||
if (isZodArray(schema)) {
|
||||
return zodObjectFieldsAreAllRequired(schema.element);
|
||||
}
|
||||
if (!isZodObject(schema)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const fieldSchema of Object.values(schema.shape)) {
|
||||
if (fieldSchema !== undefined && !zodObjectFieldsAreAllRequired(fieldSchema)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -75,6 +75,7 @@ export { ListProjectsQueryDto } from './project/list-projects-query.dto';
|
||||
export { SamlAcsDto } from './saml/saml-acs.dto';
|
||||
export { SamlPreferences } from './saml/saml-preferences.dto';
|
||||
export { SamlPreferencesAttributeMapping } from './saml/saml-preferences.dto';
|
||||
export { UpdateSamlConfigurationDto } from './saml/saml-preferences.dto';
|
||||
export { SamlToggleDto } from './saml/saml-toggle.dto';
|
||||
export { type SamlConfigurationResponse } from './saml/saml-configuration-response.dto';
|
||||
|
||||
|
||||
@@ -1,157 +1,237 @@
|
||||
import { SamlPreferences } from '../saml-preferences.dto';
|
||||
import {
|
||||
zodObjectFieldsAreAllRequired,
|
||||
zodObjectKeysMatch,
|
||||
} from '../../../__tests__/helpers/zod-object-keys-match';
|
||||
import { SamlPreferences, UpdateSamlConfigurationDto } 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',
|
||||
n8nInstanceRole: 'n8n_instance_role',
|
||||
describe('SAML preference DTOs', () => {
|
||||
it('keeps UpdateSamlConfigurationDto and SamlPreferences object keys in sync', () => {
|
||||
expect(zodObjectKeysMatch(UpdateSamlConfigurationDto.schema, SamlPreferences.schema)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('SamlPreferences', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid minimal configuration',
|
||||
request: {
|
||||
mapping: {
|
||||
email: 'user@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
userPrincipalName: 'johndoe',
|
||||
n8nInstanceRole: 'n8n_instance_role',
|
||||
},
|
||||
metadata: '<xml>metadata</xml>',
|
||||
metadataUrl: 'https://example.com/metadata',
|
||||
loginEnabled: true,
|
||||
loginLabel: 'Login with SAML',
|
||||
},
|
||||
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',
|
||||
n8nInstanceRole: 'n8n_instance_role',
|
||||
{
|
||||
name: 'valid full configuration',
|
||||
request: {
|
||||
mapping: {
|
||||
email: 'user@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
userPrincipalName: 'johndoe',
|
||||
n8nInstanceRole: 'n8n_instance_role',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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: {
|
||||
},
|
||||
])('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',
|
||||
},
|
||||
},
|
||||
relayState: 'https://example.com/relay',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = SamlPreferences.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
expect(result.data?.relayState).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid loginBinding',
|
||||
request: {
|
||||
loginBinding: 'invalid',
|
||||
},
|
||||
expectedErrorPath: ['loginBinding'],
|
||||
describe('UpdateSamlConfigurationDto', () => {
|
||||
const fullBody = {
|
||||
mapping: {
|
||||
email: 'user@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
userPrincipalName: 'johndoe',
|
||||
n8nInstanceRole: '',
|
||||
n8nProjectRoles: [] as string[],
|
||||
},
|
||||
{
|
||||
name: 'invalid acsBinding',
|
||||
request: {
|
||||
acsBinding: 'invalid',
|
||||
metadata: '',
|
||||
metadataUrl: '',
|
||||
ignoreSSL: false,
|
||||
loginBinding: 'redirect' as const,
|
||||
loginEnabled: false,
|
||||
loginLabel: 'SAML',
|
||||
authnRequestsSigned: false,
|
||||
wantAssertionsSigned: true,
|
||||
wantMessageSigned: true,
|
||||
signingPrivateKey: '',
|
||||
signingCertificate: '',
|
||||
acsBinding: 'post' as const,
|
||||
signatureConfig: {
|
||||
prefix: 'custom',
|
||||
location: {
|
||||
reference: '/samlp:Response/saml:Issuer',
|
||||
action: 'after' as const,
|
||||
},
|
||||
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);
|
||||
relayState: '',
|
||||
};
|
||||
|
||||
it('rejects an empty body with an error for every top-level field - Guards against .optional() / .default() on PUT fields', () => {
|
||||
const result = UpdateSamlConfigurationDto.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
const erroredFields = new Set(
|
||||
result.error?.issues.map((issue) => String(issue.path[0])) ?? [],
|
||||
);
|
||||
const requiredFields = Object.keys(UpdateSamlConfigurationDto.schema.shape).sort();
|
||||
|
||||
expect([...erroredFields].sort()).toEqual(requiredFields);
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('should handle optional fields correctly', () => {
|
||||
const validRequest = {
|
||||
mapping: undefined,
|
||||
metadata: undefined,
|
||||
metadataUrl: undefined,
|
||||
loginEnabled: undefined,
|
||||
loginLabel: undefined,
|
||||
};
|
||||
it('requires every nested field with no optional or default', () => {
|
||||
expect(zodObjectFieldsAreAllRequired(UpdateSamlConfigurationDto.schema)).toBe(true);
|
||||
});
|
||||
|
||||
const result = SamlPreferences.safeParse(validRequest);
|
||||
expect(result.success).toBe(true);
|
||||
it('accepts a complete signatureConfig', () => {
|
||||
const result = UpdateSamlConfigurationDto.safeParse(fullBody);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.signatureConfig.prefix).toBe('custom');
|
||||
});
|
||||
|
||||
it('rejects signatureConfig when prefix is omitted instead of defaulting to ds', () => {
|
||||
const { prefix: _prefix, ...locationOnly } = fullBody.signatureConfig;
|
||||
const result = UpdateSamlConfigurationDto.safeParse({
|
||||
...fullBody,
|
||||
signatureConfig: {
|
||||
location: locationOnly.location,
|
||||
},
|
||||
});
|
||||
|
||||
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('');
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['signatureConfig', 'prefix']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { SamlPreferences } from './saml-preferences.dto';
|
||||
import type { UpdateSamlConfigurationDto } from './saml-preferences.dto';
|
||||
|
||||
/**
|
||||
* Public API response shape for the SAML SSO configuration group.
|
||||
* Derived from the PUT body plus read-only service-provider fields so a GET
|
||||
* response can be sent back as a PUT body without drifting out of sync.
|
||||
*/
|
||||
export type SamlConfigurationResponse = SamlPreferences & {
|
||||
export type SamlConfigurationResponse = UpdateSamlConfigurationDto & {
|
||||
entityID: string;
|
||||
returnUrl: string;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,15 @@ const SignatureConfigSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/** Same shape without defaults — required for full-object Public API PUTs. */
|
||||
const SignatureConfigRequiredSchema = z.object({
|
||||
prefix: z.string(),
|
||||
location: z.object({
|
||||
reference: z.string(),
|
||||
action: z.enum(['before', 'after', 'prepend', 'append']),
|
||||
}),
|
||||
});
|
||||
|
||||
export class SamlPreferencesAttributeMapping extends Z.class({
|
||||
/** SAML attribute mapped to the user's email. */
|
||||
email: z.string(),
|
||||
@@ -62,3 +71,32 @@ export class SamlPreferences extends Z.class({
|
||||
|
||||
relayState: z.string().default(''),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Public API PUT body for SAML configuration. Clients must send every writable
|
||||
* field; use empty strings / empty arrays when a value is unset.
|
||||
*/
|
||||
export class UpdateSamlConfigurationDto extends Z.class({
|
||||
mapping: z.object({
|
||||
email: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
userPrincipalName: z.string(),
|
||||
n8nInstanceRole: z.string(),
|
||||
n8nProjectRoles: z.array(z.string()),
|
||||
}),
|
||||
metadata: z.string(),
|
||||
metadataUrl: z.string(),
|
||||
ignoreSSL: z.boolean(),
|
||||
loginBinding: SamlLoginBindingSchema,
|
||||
loginEnabled: z.boolean(),
|
||||
loginLabel: z.string(),
|
||||
authnRequestsSigned: z.boolean(),
|
||||
wantAssertionsSigned: z.boolean(),
|
||||
wantMessageSigned: z.boolean(),
|
||||
signingPrivateKey: z.string(),
|
||||
signingCertificate: z.string(),
|
||||
acsBinding: SamlLoginBindingSchema,
|
||||
signatureConfig: SignatureConfigRequiredSchema,
|
||||
relayState: z.string(),
|
||||
}) {}
|
||||
|
||||
@@ -8,14 +8,15 @@ const redactionEnforcementFieldSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Public API request body for updating the security policy group. Mirrors the
|
||||
* writable subset of the internal security settings, validated with the same
|
||||
* field schemas so behaviour stays in sync.
|
||||
* Public API PUT body for the security policy group. Clients must send the full
|
||||
* writable configuration; partial updates are rejected. Mirrors the writable
|
||||
* subset of the internal security settings, validated with the same field
|
||||
* schemas so behaviour stays in sync.
|
||||
*/
|
||||
export class UpdateSecurityPolicyDto extends Z.class({
|
||||
personalSpacePublishing: z.boolean().optional(),
|
||||
personalSpaceSharing: z.boolean().optional(),
|
||||
redactionEnforcement: redactionEnforcementFieldSchema.optional(),
|
||||
personalSpacePublishing: z.boolean(),
|
||||
personalSpaceSharing: z.boolean(),
|
||||
redactionEnforcement: redactionEnforcementFieldSchema,
|
||||
}) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -809,6 +809,46 @@ describe('SamlService', () => {
|
||||
expect(samlService.samlPreferences.metadataUrl).toBe(metadataUrlTestData);
|
||||
});
|
||||
|
||||
test('clears metadata and metadataUrl when set to empty strings', async () => {
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadata: '<xml>existing</xml>',
|
||||
metadataUrl: 'https://idp.example.com/metadata',
|
||||
});
|
||||
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadata: '',
|
||||
metadataUrl: '',
|
||||
});
|
||||
|
||||
expect(samlService.samlPreferences.metadata).toBe('');
|
||||
expect(samlService.samlPreferences.metadataUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('clears metadataUrl when empty string is provided alone', async () => {
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadataUrl: 'https://idp.example.com/metadata',
|
||||
});
|
||||
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadataUrl: '',
|
||||
});
|
||||
|
||||
expect(samlService.samlPreferences.metadataUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('clears metadataUrl when XML metadata is provided without a URL', async () => {
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadataUrl: 'https://idp.example.com/metadata',
|
||||
});
|
||||
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadata: '<xml>new</xml>',
|
||||
});
|
||||
|
||||
expect(samlService.samlPreferences.metadata).toBe('<xml>new</xml>');
|
||||
expect(samlService.samlPreferences.metadataUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('does throw `InvalidSamlMetadataError` in case saml login is turned on and no valid metadata is available', async () => {
|
||||
await samlService.loadPreferencesWithoutValidation({
|
||||
metadata: 'not valid data',
|
||||
|
||||
@@ -564,12 +564,23 @@ export class SamlService {
|
||||
throw new InvalidSamlMetadataError();
|
||||
}
|
||||
}
|
||||
this.getIdentityProviderInstance(true);
|
||||
if (this._samlPreferences.metadata) {
|
||||
this.getIdentityProviderInstance(true);
|
||||
} else {
|
||||
// Metadata was cleared — drop the cached IdP so a later configure can recreate it.
|
||||
this.identityProviderInstance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async loadPreferencesWithoutValidation(prefs: Partial<SamlPreferences>) {
|
||||
await this.applySamlPreferenceFields(prefs);
|
||||
this.applyIdpMetadataPreferences(prefs);
|
||||
await setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled());
|
||||
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
|
||||
}
|
||||
|
||||
private async applySamlPreferenceFields(prefs: Partial<SamlPreferences>) {
|
||||
this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
|
||||
this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
|
||||
this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
|
||||
this._samlPreferences.ignoreSSL = prefs.ignoreSSL ?? this._samlPreferences.ignoreSSL;
|
||||
this._samlPreferences.acsBinding = prefs.acsBinding ?? this._samlPreferences.acsBinding;
|
||||
@@ -581,12 +592,14 @@ export class SamlService {
|
||||
prefs.wantAssertionsSigned ?? this._samlPreferences.wantAssertionsSigned;
|
||||
this._samlPreferences.wantMessageSigned =
|
||||
prefs.wantMessageSigned ?? this._samlPreferences.wantMessageSigned;
|
||||
|
||||
if (prefs.signingCertificate === '') {
|
||||
this._samlPreferences.signingCertificate = undefined;
|
||||
} else {
|
||||
this._samlPreferences.signingCertificate =
|
||||
prefs.signingCertificate ?? this._samlPreferences.signingCertificate;
|
||||
}
|
||||
|
||||
if (
|
||||
prefs.signingPrivateKey !== undefined &&
|
||||
prefs.signingPrivateKey !== CREDENTIAL_BLANKING_VALUE
|
||||
@@ -604,15 +617,22 @@ export class SamlService {
|
||||
this._samlPreferences.signingPrivateKey = prefs.signingPrivateKey;
|
||||
}
|
||||
}
|
||||
if (prefs.metadataUrl) {
|
||||
this._samlPreferences.metadataUrl = prefs.metadataUrl;
|
||||
} else if (prefs.metadata) {
|
||||
// remove metadataUrl if metadata is set directly
|
||||
this._samlPreferences.metadataUrl = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply IdP metadata sources. `undefined` leaves the field unchanged; `''` clears it
|
||||
* (PUT replacement). Providing XML without a URL also clears the URL alternate.
|
||||
*/
|
||||
private applyIdpMetadataPreferences(prefs: Partial<SamlPreferences>) {
|
||||
if (prefs.metadata !== undefined) {
|
||||
this._samlPreferences.metadata = prefs.metadata;
|
||||
}
|
||||
await setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled());
|
||||
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
|
||||
if (prefs.metadataUrl !== undefined) {
|
||||
this._samlPreferences.metadataUrl = prefs.metadataUrl || undefined;
|
||||
}
|
||||
if (prefs.metadata && !prefs.metadataUrl) {
|
||||
this._samlPreferences.metadataUrl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromDbAndApplySamlPreferences(
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
UpsertDataTableRowDto,
|
||||
UpdateSecurityPolicyDto,
|
||||
PublicCreateDestination,
|
||||
UpdateSamlConfigurationDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest, TagEntity, WorkflowEntity } from '@n8n/db';
|
||||
import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
@@ -418,4 +419,5 @@ export declare namespace LogStreamingRequest {
|
||||
|
||||
export declare namespace SsoSamlRequest {
|
||||
type Get = AuthenticatedRequest;
|
||||
type Update = AuthenticatedRequest<{}, {}, UpdateSamlConfigurationDto>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BadRequest } from 'express-openapi-validator/dist/framework/types';
|
||||
import { UnexpectedError, UserError, OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { sendPublicApiErrorResponse } from '../public-api-error-response';
|
||||
@@ -40,6 +41,13 @@ describe('sendPublicApiErrorResponse', () => {
|
||||
expect(res._payload.body).toEqual({ message: 'invalid' });
|
||||
});
|
||||
|
||||
it('maps ConflictError to 409', () => {
|
||||
const res = createMockRes();
|
||||
sendPublicApiErrorResponse(res, new ConflictError('managed declaratively'));
|
||||
expect(res._payload.statusCode).toBe(409);
|
||||
expect(res._payload.body).toEqual({ message: 'managed declaratively' });
|
||||
});
|
||||
|
||||
it('maps UserError to 400', () => {
|
||||
const res = createMockRes();
|
||||
sendPublicApiErrorResponse(res, new UserError('bad input'));
|
||||
|
||||
+9
-7
@@ -26,15 +26,17 @@ put:
|
||||
x-eov-operation-handler: v1/handlers/security-policy/security-policy.handler
|
||||
tags:
|
||||
- SecurityPolicy
|
||||
summary: Update the security policy
|
||||
summary: Set the security policy
|
||||
description: >
|
||||
Update the instance security policy. Only the provided fields are changed and the update
|
||||
takes effect exactly as it would from the UI, using the same validation. Requires the
|
||||
`securitySettings:manage` scope and the Personal Space Policy feature to be licensed.
|
||||
When the group is managed via environment variables, the write is rejected with 409 and no
|
||||
changes are made; a read still returns the current values.
|
||||
Replace the instance security policy with the provided full object. Every writable
|
||||
field must be sent. Read-only usage counts from GET are ignored if included, so a GET
|
||||
response can be sent back as a PUT body. The update takes effect exactly as it would
|
||||
from the UI, using the same validation. Requires the `securitySettings:manage` scope
|
||||
and the Personal Space Policy feature to be licensed. When the group is managed via
|
||||
environment variables, the write is rejected with 409 and no changes are made; a read
|
||||
still returns the current values.
|
||||
requestBody:
|
||||
description: The security policy fields to update.
|
||||
description: The full security policy to set.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
|
||||
+20
-1
@@ -1,6 +1,10 @@
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: Security policy fields to update. Omitted fields are left unchanged.
|
||||
description: Full security policy. All writable fields must be provided; partial updates are not supported.
|
||||
required:
|
||||
- personalSpacePublishing
|
||||
- personalSpaceSharing
|
||||
- redactionEnforcement
|
||||
properties:
|
||||
personalSpacePublishing:
|
||||
type: boolean
|
||||
@@ -21,3 +25,18 @@ properties:
|
||||
enum: [off, production, all]
|
||||
description: Minimum execution-data redaction level enforced across the instance.
|
||||
example: production
|
||||
publishedPersonalWorkflowsCount:
|
||||
type: integer
|
||||
description: >
|
||||
Read-only usage count returned by GET. Ignored on write so a GET response can be
|
||||
sent back as a PUT body.
|
||||
sharedPersonalWorkflowsCount:
|
||||
type: integer
|
||||
description: >
|
||||
Read-only usage count returned by GET. Ignored on write so a GET response can be
|
||||
sent back as a PUT body.
|
||||
sharedPersonalCredentialsCount:
|
||||
type: integer
|
||||
description: >
|
||||
Read-only usage count returned by GET. Ignored on write so a GET response can be
|
||||
sent back as a PUT body.
|
||||
|
||||
@@ -21,3 +21,41 @@ get:
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'403':
|
||||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
put:
|
||||
x-eov-operation-id: updateSamlConfiguration
|
||||
x-required-scope: saml:manage
|
||||
x-eov-operation-handler: v1/handlers/sso-saml/sso-saml.handler
|
||||
tags:
|
||||
- SettingsSsoSaml
|
||||
summary: Set the SAML SSO configuration
|
||||
description: >
|
||||
Replace the SAML SSO configuration with the provided full object. Every writable field
|
||||
must be sent; use empty strings or empty arrays when a value is unset. Read-only
|
||||
`entityID` / `returnUrl` from GET are ignored if included, so a GET response can be
|
||||
sent back as a PUT body. Redacted secret placeholders keep the stored values unchanged.
|
||||
The update takes effect exactly as it would from the UI, using the same validation.
|
||||
Requires the `saml:manage` scope and the SAML feature to be licensed. When the
|
||||
configuration is managed via environment variables, the write is rejected with 409 and no
|
||||
changes are made.
|
||||
requestBody:
|
||||
description: The full SAML SSO configuration to set.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/saml-configuration.update.yml'
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/saml-configuration.yml'
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'403':
|
||||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
'409':
|
||||
$ref: '../../../../shared/spec/responses/conflict.yml'
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: >
|
||||
Full SAML SSO configuration. Every field must be provided; use empty strings or
|
||||
empty arrays when a value is unset. Partial updates are not supported.
|
||||
required:
|
||||
- mapping
|
||||
- metadata
|
||||
- metadataUrl
|
||||
- ignoreSSL
|
||||
- loginBinding
|
||||
- loginEnabled
|
||||
- loginLabel
|
||||
- authnRequestsSigned
|
||||
- wantAssertionsSigned
|
||||
- wantMessageSigned
|
||||
- signingPrivateKey
|
||||
- signingCertificate
|
||||
- acsBinding
|
||||
- signatureConfig
|
||||
- relayState
|
||||
properties:
|
||||
mapping:
|
||||
type: object
|
||||
description: Mapping of SAML attributes to n8n user fields. Use empty strings / empty arrays for unused attributes.
|
||||
additionalProperties: false
|
||||
required:
|
||||
- email
|
||||
- firstName
|
||||
- lastName
|
||||
- userPrincipalName
|
||||
- n8nInstanceRole
|
||||
- n8nProjectRoles
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: SAML attribute mapped to the user's email.
|
||||
firstName:
|
||||
type: string
|
||||
description: SAML attribute mapped to the user's first name.
|
||||
lastName:
|
||||
type: string
|
||||
description: SAML attribute mapped to the user's last name.
|
||||
userPrincipalName:
|
||||
type: string
|
||||
description: SAML attribute mapped to the user's principal name.
|
||||
n8nInstanceRole:
|
||||
type: string
|
||||
description: SAML attribute mapped to the n8n instance role. Use an empty string when unused.
|
||||
n8nProjectRoles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
SAML attributes mapped to n8n project roles, formatted as `<projectId>:<role>`.
|
||||
Use an empty array when unused.
|
||||
metadata:
|
||||
type: string
|
||||
description: >
|
||||
Identity provider metadata in XML format. Use an empty string to clear stored metadata
|
||||
(also clears metadataUrl when no URL is provided). Use the redaction placeholder from a
|
||||
prior GET to leave an existing value unchanged.
|
||||
metadataUrl:
|
||||
type: string
|
||||
description: >
|
||||
URL to fetch identity provider metadata from. Use an empty string to clear a stored URL.
|
||||
ignoreSSL:
|
||||
type: boolean
|
||||
description: Whether to ignore SSL certificate errors when fetching metadata from a URL.
|
||||
example: false
|
||||
loginBinding:
|
||||
type: string
|
||||
enum: [redirect, post]
|
||||
description: SAML login request binding.
|
||||
example: redirect
|
||||
loginEnabled:
|
||||
type: boolean
|
||||
description: Whether SAML login is enabled.
|
||||
example: false
|
||||
loginLabel:
|
||||
type: string
|
||||
description: Label shown on the SAML login button.
|
||||
example: SAML
|
||||
authnRequestsSigned:
|
||||
type: boolean
|
||||
description: Whether authentication requests are signed.
|
||||
example: false
|
||||
wantAssertionsSigned:
|
||||
type: boolean
|
||||
description: Whether signed assertions are required.
|
||||
example: true
|
||||
wantMessageSigned:
|
||||
type: boolean
|
||||
description: Whether signed SAML messages are required.
|
||||
example: true
|
||||
signingPrivateKey:
|
||||
type: string
|
||||
description: >
|
||||
PEM-encoded private key for signing SAML AuthnRequests. Use an empty string to clear
|
||||
an existing key, or the redaction placeholder from a prior GET to leave it unchanged.
|
||||
signingCertificate:
|
||||
type: string
|
||||
description: >
|
||||
PEM-encoded certificate containing the public key matching the signing private key.
|
||||
Use an empty string when unused or to clear an existing certificate.
|
||||
acsBinding:
|
||||
type: string
|
||||
enum: [redirect, post]
|
||||
description: Assertion Consumer Service binding.
|
||||
example: post
|
||||
signatureConfig:
|
||||
type: object
|
||||
description: Configuration for the signature in SAML requests and responses.
|
||||
additionalProperties: false
|
||||
required:
|
||||
- prefix
|
||||
- location
|
||||
properties:
|
||||
prefix:
|
||||
type: string
|
||||
example: ds
|
||||
location:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- reference
|
||||
- action
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
example: /samlp:Response/saml:Issuer
|
||||
action:
|
||||
type: string
|
||||
enum: [before, after, prepend, append]
|
||||
example: after
|
||||
relayState:
|
||||
type: string
|
||||
description: Default relay state value for SAML requests. Use an empty string when unused.
|
||||
example: https://n8n.example.com
|
||||
entityID:
|
||||
type: string
|
||||
description: >
|
||||
Service provider entity ID. Returned by GET for convenience; ignored on write so a GET
|
||||
response can be sent back as a PUT body.
|
||||
example: https://n8n.example.com/rest/sso/saml/metadata
|
||||
returnUrl:
|
||||
type: string
|
||||
description: >
|
||||
Assertion Consumer Service return URL. Returned by GET for convenience; ignored on write
|
||||
so a GET response can be sent back as a PUT body.
|
||||
example: https://n8n.example.com/rest/sso/saml/acs
|
||||
+26
-6
@@ -3,12 +3,19 @@ additionalProperties: false
|
||||
required:
|
||||
- entityID
|
||||
- returnUrl
|
||||
- mapping
|
||||
- metadata
|
||||
- metadataUrl
|
||||
- ignoreSSL
|
||||
- loginBinding
|
||||
- acsBinding
|
||||
- loginEnabled
|
||||
- loginLabel
|
||||
- authnRequestsSigned
|
||||
- wantAssertionsSigned
|
||||
- wantMessageSigned
|
||||
- signingPrivateKey
|
||||
- signingCertificate
|
||||
- acsBinding
|
||||
- signatureConfig
|
||||
- relayState
|
||||
properties:
|
||||
@@ -26,6 +33,13 @@ properties:
|
||||
type: object
|
||||
description: Mapping of SAML attributes to n8n user fields.
|
||||
additionalProperties: false
|
||||
required:
|
||||
- email
|
||||
- firstName
|
||||
- lastName
|
||||
- userPrincipalName
|
||||
- n8nInstanceRole
|
||||
- n8nProjectRoles
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
@@ -51,11 +65,11 @@ properties:
|
||||
type: string
|
||||
description: >
|
||||
Identity provider metadata in XML format. Redacted on read when set because it
|
||||
contains IdP certificates; never echoed back in plaintext.
|
||||
contains IdP certificates; never echoed back in plaintext. Use an empty string when unset.
|
||||
example: '**hidden**'
|
||||
metadataUrl:
|
||||
type: string
|
||||
description: URL to fetch identity provider metadata from.
|
||||
description: URL to fetch identity provider metadata from. Use an empty string when unset.
|
||||
ignoreSSL:
|
||||
type: boolean
|
||||
description: Whether to ignore SSL certificate errors when fetching metadata from a URL.
|
||||
@@ -89,13 +103,13 @@ properties:
|
||||
type: string
|
||||
description: >
|
||||
PEM-encoded private key for signing SAML AuthnRequests. Redacted on read when set;
|
||||
never echoed back in plaintext.
|
||||
never echoed back in plaintext. Use an empty string when unset.
|
||||
example: '**hidden**'
|
||||
signingCertificate:
|
||||
type: string
|
||||
description: >
|
||||
PEM-encoded certificate containing the public key matching the signing private key.
|
||||
Redacted on read when set; never echoed back in plaintext.
|
||||
Redacted on read when set; never echoed back in plaintext. Use an empty string when unset.
|
||||
example: '**hidden**'
|
||||
acsBinding:
|
||||
type: string
|
||||
@@ -106,6 +120,9 @@ properties:
|
||||
type: object
|
||||
description: Configuration for the signature in SAML requests and responses.
|
||||
additionalProperties: false
|
||||
required:
|
||||
- prefix
|
||||
- location
|
||||
properties:
|
||||
prefix:
|
||||
type: string
|
||||
@@ -113,6 +130,9 @@ properties:
|
||||
location:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- reference
|
||||
- action
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
@@ -123,5 +143,5 @@ properties:
|
||||
example: after
|
||||
relayState:
|
||||
type: string
|
||||
description: Default relay state value for SAML requests.
|
||||
description: Default relay state value for SAML requests. Use an empty string when unset.
|
||||
example: https://n8n.example.com
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { UpdateSamlConfigurationDto } from '@n8n/api-types';
|
||||
import { InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { SamlService } from '@/modules/sso-saml/saml.service.ee';
|
||||
|
||||
import { toSamlConfigurationResponse, toSamlPreferencesUpdate } from './sso-saml.mapper';
|
||||
import type { SsoSamlRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import {
|
||||
apiKeyHasScopeWithGlobalScopeFallback,
|
||||
isLicensed,
|
||||
} from '../../shared/middlewares/global.middleware';
|
||||
import { toSamlConfigurationResponse } from './sso-saml.mapper';
|
||||
|
||||
type SsoSamlHandlers = {
|
||||
getSamlConfiguration: PublicAPIEndpoint<SsoSamlRequest.Get>;
|
||||
updateSamlConfiguration: PublicAPIEndpoint<SsoSamlRequest.Update>;
|
||||
};
|
||||
|
||||
const ssoSamlHandlers: SsoSamlHandlers = {
|
||||
@@ -24,6 +29,29 @@ const ssoSamlHandlers: SsoSamlHandlers = {
|
||||
return res.json(toSamlConfigurationResponse(samlService.samlPreferences));
|
||||
},
|
||||
],
|
||||
|
||||
updateSamlConfiguration: [
|
||||
isLicensed('feat:saml'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'saml:manage' }),
|
||||
async (req, res) => {
|
||||
const payload = UpdateSamlConfigurationDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
throw new BadRequestError(payload.error.errors[0]?.message ?? 'Invalid request body');
|
||||
}
|
||||
|
||||
const { ssoManagedByEnv } = Container.get(InstanceSettingsLoaderConfig);
|
||||
if (ssoManagedByEnv) {
|
||||
throw new ConflictError(
|
||||
'SSO configuration is managed declaratively and cannot be modified through the API',
|
||||
);
|
||||
}
|
||||
|
||||
const samlService = Container.get(SamlService);
|
||||
await samlService.setSamlPreferences(toSamlPreferencesUpdate(payload.data));
|
||||
|
||||
return res.json(toSamlConfigurationResponse(samlService.samlPreferences));
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export = ssoSamlHandlers;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SamlConfigurationResponse, SamlPreferences } from '@n8n/api-types';
|
||||
import type {
|
||||
SamlConfigurationResponse,
|
||||
SamlPreferences,
|
||||
UpdateSamlConfigurationDto,
|
||||
} from '@n8n/api-types';
|
||||
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
@@ -6,13 +10,63 @@ import {
|
||||
getServiceProviderReturnUrl,
|
||||
} from '@/modules/sso-saml/service-provider.ee';
|
||||
|
||||
/**
|
||||
* Normalize preferences into the public API response shape.
|
||||
*
|
||||
* Every writable PUT field is always present so a GET response can be sent back
|
||||
* as a PUT body. Secrets are redacted with the blanking placeholder when set, or
|
||||
* `""` when unset. Read-only `entityID` / `returnUrl` are included and ignored on write.
|
||||
*/
|
||||
export function toSamlConfigurationResponse(prefs: SamlPreferences): SamlConfigurationResponse {
|
||||
return {
|
||||
...prefs,
|
||||
metadata: prefs.metadata ? CREDENTIAL_BLANKING_VALUE : undefined,
|
||||
signingPrivateKey: prefs.signingPrivateKey ? CREDENTIAL_BLANKING_VALUE : undefined,
|
||||
signingCertificate: prefs.signingCertificate ? CREDENTIAL_BLANKING_VALUE : undefined,
|
||||
mapping: {
|
||||
email: prefs.mapping?.email ?? '',
|
||||
firstName: prefs.mapping?.firstName ?? '',
|
||||
lastName: prefs.mapping?.lastName ?? '',
|
||||
userPrincipalName: prefs.mapping?.userPrincipalName ?? '',
|
||||
n8nInstanceRole: prefs.mapping?.n8nInstanceRole ?? '',
|
||||
n8nProjectRoles: prefs.mapping?.n8nProjectRoles ?? [],
|
||||
},
|
||||
metadata: prefs.metadata ? CREDENTIAL_BLANKING_VALUE : '',
|
||||
metadataUrl: prefs.metadataUrl ?? '',
|
||||
ignoreSSL: prefs.ignoreSSL ?? false,
|
||||
loginBinding: prefs.loginBinding ?? 'redirect',
|
||||
loginEnabled: prefs.loginEnabled ?? false,
|
||||
loginLabel: prefs.loginLabel ?? '',
|
||||
authnRequestsSigned: prefs.authnRequestsSigned ?? false,
|
||||
wantAssertionsSigned: prefs.wantAssertionsSigned ?? true,
|
||||
wantMessageSigned: prefs.wantMessageSigned ?? true,
|
||||
signingPrivateKey: prefs.signingPrivateKey ? CREDENTIAL_BLANKING_VALUE : '',
|
||||
signingCertificate: prefs.signingCertificate ? CREDENTIAL_BLANKING_VALUE : '',
|
||||
acsBinding: prefs.acsBinding ?? 'post',
|
||||
signatureConfig: prefs.signatureConfig ?? {
|
||||
prefix: 'ds',
|
||||
location: {
|
||||
reference: '/samlp:Response/saml:Issuer',
|
||||
action: 'after',
|
||||
},
|
||||
},
|
||||
relayState: prefs.relayState ?? '',
|
||||
entityID: getServiceProviderEntityId(),
|
||||
returnUrl: getServiceProviderReturnUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a validated PUT body into preferences for `setSamlPreferences`.
|
||||
* Treats redaction placeholders as "keep existing" (omit the field). The service
|
||||
* already does this for `signingPrivateKey`; we mirror it here for metadata and
|
||||
* signingCertificate, which the service does not handle the same way.
|
||||
*/
|
||||
export function toSamlPreferencesUpdate(
|
||||
data: UpdateSamlConfigurationDto,
|
||||
): Partial<SamlPreferences> {
|
||||
const { metadata, signingCertificate, signingPrivateKey, ...rest } = data;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(metadata === CREDENTIAL_BLANKING_VALUE ? {} : { metadata }),
|
||||
...(signingCertificate === CREDENTIAL_BLANKING_VALUE ? {} : { signingCertificate }),
|
||||
...(signingPrivateKey === CREDENTIAL_BLANKING_VALUE ? {} : { signingPrivateKey }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,13 +106,19 @@ describe('Security policy in Public API', () => {
|
||||
});
|
||||
|
||||
describe('PUT /settings/security-policy', () => {
|
||||
const fullPolicy = {
|
||||
personalSpacePublishing: false,
|
||||
personalSpaceSharing: true,
|
||||
redactionEnforcement: { floor: 'production' as const },
|
||||
};
|
||||
|
||||
it('updates the policy and returns the new values', async () => {
|
||||
testServer.license.enable('feat:personalSpacePolicy');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: false, redactionEnforcement: { floor: 'production' } });
|
||||
.send(fullPolicy);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
@@ -127,17 +133,54 @@ describe('Security policy in Public API', () => {
|
||||
.get('/settings/security-policy');
|
||||
expect(readResponse.body).toMatchObject({
|
||||
personalSpacePublishing: false,
|
||||
personalSpaceSharing: true,
|
||||
redactionEnforcement: { floor: 'production' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a GET response body as a PUT body', async () => {
|
||||
testServer.license.enable('feat:personalSpacePolicy');
|
||||
|
||||
const getResponse = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.get('/settings/security-policy');
|
||||
expect(getResponse.status).toBe(200);
|
||||
|
||||
const putResponse = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({
|
||||
...getResponse.body,
|
||||
personalSpacePublishing: false,
|
||||
});
|
||||
|
||||
expect(putResponse.status).toBe(200);
|
||||
expect(putResponse.body.personalSpacePublishing).toBe(false);
|
||||
expect(putResponse.body.publishedPersonalWorkflowsCount).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a partial request body with 400', async () => {
|
||||
testServer.license.enable('feat:personalSpacePolicy');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: false });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a malformed request body with 400', async () => {
|
||||
testServer.license.enable('feat:personalSpacePolicy');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: 'not-a-boolean', redactionEnforcement: 'nope' });
|
||||
.send({
|
||||
personalSpacePublishing: 'not-a-boolean',
|
||||
personalSpaceSharing: true,
|
||||
redactionEnforcement: 'nope',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
@@ -148,7 +191,11 @@ describe('Security policy in Public API', () => {
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ redactionEnforcement: { floor: 'bogus' } });
|
||||
.send({
|
||||
personalSpacePublishing: false,
|
||||
personalSpaceSharing: true,
|
||||
redactionEnforcement: { floor: 'bogus' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('message');
|
||||
@@ -160,7 +207,7 @@ describe('Security policy in Public API', () => {
|
||||
const response = await testServer
|
||||
.publicApiAgentWithoutApiKey()
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: false });
|
||||
.send(fullPolicy);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
@@ -169,7 +216,7 @@ describe('Security policy in Public API', () => {
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: false });
|
||||
.send(fullPolicy);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', licenseErrorMessage);
|
||||
@@ -182,7 +229,7 @@ describe('Security policy in Public API', () => {
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(scopedOwner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: false });
|
||||
.send(fullPolicy);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
@@ -194,7 +241,7 @@ describe('Security policy in Public API', () => {
|
||||
const writeResponse = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/security-policy')
|
||||
.send({ personalSpacePublishing: false });
|
||||
.send(fullPolicy);
|
||||
|
||||
expect(writeResponse.status).toBe(409);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
|
||||
@@ -20,17 +21,23 @@ describe('SAML SSO configuration in Public API', () => {
|
||||
});
|
||||
const licenseErrorMessage = new FeatureNotLicensedError('feat:saml').message;
|
||||
|
||||
const setManagedByEnv = (value: boolean) => {
|
||||
Container.get(InstanceSettingsLoaderConfig).ssoManagedByEnv = value;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
setManagedByEnv(false);
|
||||
owner = await createOwnerWithApiKey();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS;
|
||||
setManagedByEnv(false);
|
||||
});
|
||||
|
||||
describe('GET /settings/sso/saml', () => {
|
||||
@@ -76,6 +83,10 @@ describe('SAML SSO configuration in Public API', () => {
|
||||
expect(response.body.metadata).toBe(CREDENTIAL_BLANKING_VALUE);
|
||||
expect(response.body.metadata).not.toContain('BEGIN CERTIFICATE');
|
||||
expect(response.body.signingCertificate).not.toContain('BEGIN CERTIFICATE');
|
||||
expect(response.body.mapping).toMatchObject({
|
||||
n8nInstanceRole: expect.any(String),
|
||||
n8nProjectRoles: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects with 403 when not licensed', async () => {
|
||||
@@ -113,4 +124,191 @@ describe('SAML SSO configuration in Public API', () => {
|
||||
expect(response.body.loginLabel).toBe(samlService.samlPreferences.loginLabel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /settings/sso/saml', () => {
|
||||
it('sets the SAML configuration and returns the updated values', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({
|
||||
...sampleConfig,
|
||||
loginLabel: 'Updated SAML Label',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
loginLabel: 'Updated SAML Label',
|
||||
loginEnabled: sampleConfig.loginEnabled,
|
||||
ignoreSSL: sampleConfig.ignoreSSL,
|
||||
});
|
||||
expect(response.body.entityID).toContain('/rest/sso/saml/metadata');
|
||||
expect(response.body.returnUrl).toContain('/rest/sso/saml/acs');
|
||||
|
||||
const readResponse = await testServer.publicApiAgentFor(owner).get('/settings/sso/saml');
|
||||
expect(readResponse.body.loginLabel).toBe('Updated SAML Label');
|
||||
});
|
||||
|
||||
it('accepts a GET response body as a PUT body and preserves redacted secrets', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
|
||||
|
||||
await testServer
|
||||
.authAgentFor(owner)
|
||||
.post('/sso/saml/config')
|
||||
.send({
|
||||
...sampleConfig,
|
||||
signingPrivateKey: RSA_TEST_PRIVATE_KEY,
|
||||
signingCertificate: RSA_TEST_CERTIFICATE,
|
||||
});
|
||||
|
||||
const getResponse = await testServer.publicApiAgentFor(owner).get('/settings/sso/saml');
|
||||
expect(getResponse.status).toBe(200);
|
||||
|
||||
const putResponse = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({
|
||||
...getResponse.body,
|
||||
loginLabel: 'Round-tripped Label',
|
||||
});
|
||||
|
||||
expect(putResponse.status).toBe(200);
|
||||
expect(putResponse.body.loginLabel).toBe('Round-tripped Label');
|
||||
expect(putResponse.body.signingPrivateKey).toBe(CREDENTIAL_BLANKING_VALUE);
|
||||
expect(putResponse.body.signingCertificate).toBe(CREDENTIAL_BLANKING_VALUE);
|
||||
expect(putResponse.body.metadata).toBe(CREDENTIAL_BLANKING_VALUE);
|
||||
|
||||
const samlService = Container.get(SamlService);
|
||||
expect(samlService.samlPreferences.signingPrivateKey).toBeTruthy();
|
||||
expect(samlService.samlPreferences.signingCertificate).toBe(RSA_TEST_CERTIFICATE);
|
||||
expect(samlService.samlPreferences.metadata).toContain('EntityDescriptor');
|
||||
});
|
||||
|
||||
it('clears IdP metadata when metadata and metadataUrl are sent as empty strings', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
|
||||
await testServer.publicApiAgentFor(owner).put('/settings/sso/saml').send(sampleConfig);
|
||||
|
||||
const clearResponse = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({
|
||||
...sampleConfig,
|
||||
metadata: '',
|
||||
metadataUrl: '',
|
||||
});
|
||||
|
||||
expect(clearResponse.status).toBe(200);
|
||||
expect(clearResponse.body.metadata).toBe('');
|
||||
expect(clearResponse.body.metadataUrl).toBe('');
|
||||
|
||||
const samlService = Container.get(SamlService);
|
||||
expect(samlService.samlPreferences.metadata).toBe('');
|
||||
expect(samlService.samlPreferences.metadataUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a partial request body with 400', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({ loginLabel: 'SAML' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a malformed request body with 400', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({
|
||||
...sampleConfig,
|
||||
ignoreSSL: 'not-a-boolean',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a well-formed body with invalid values with 400', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
process.env.N8N_ENV_FEAT_SIGNED_SAML_REQUESTS = 'true';
|
||||
|
||||
const samlService = Container.get(SamlService);
|
||||
type PrivatePrefs = { _samlPreferences: typeof samlService.samlPreferences };
|
||||
(samlService as unknown as PrivatePrefs)._samlPreferences.signingPrivateKey = undefined;
|
||||
(samlService as unknown as PrivatePrefs)._samlPreferences.signingCertificate = undefined;
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({
|
||||
...sampleConfig,
|
||||
authnRequestsSigned: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('rejects with 401 without a valid API key', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentWithoutApiKey()
|
||||
.put('/settings/sso/saml')
|
||||
.send(sampleConfig);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects with 403 when not licensed', async () => {
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send(sampleConfig);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', licenseErrorMessage);
|
||||
});
|
||||
|
||||
it('rejects with 403 when the API key lacks the saml:manage scope', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
const scopedOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] });
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(scopedOwner)
|
||||
.put('/settings/sso/saml')
|
||||
.send(sampleConfig);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a write with 409 when managed declaratively, but still allows reads', async () => {
|
||||
testServer.license.enable('feat:saml');
|
||||
setManagedByEnv(true);
|
||||
|
||||
const writeResponse = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.put('/settings/sso/saml')
|
||||
.send({
|
||||
...sampleConfig,
|
||||
loginLabel: 'Blocked Label',
|
||||
});
|
||||
|
||||
expect(writeResponse.status).toBe(409);
|
||||
expect(writeResponse.body).toMatchObject({
|
||||
message:
|
||||
'SSO configuration is managed declaratively and cannot be modified through the API',
|
||||
});
|
||||
|
||||
const readResponse = await testServer.publicApiAgentFor(owner).get('/settings/sso/saml');
|
||||
expect(readResponse.status).toBe(200);
|
||||
expect(readResponse.body.loginLabel).not.toBe('Blocked Label');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const sampleConfig: SamlPreferences = {
|
||||
lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname',
|
||||
userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn',
|
||||
n8nInstanceRole: 'n8n_instance_role',
|
||||
n8nProjectRoles: [],
|
||||
},
|
||||
metadata: sampleMetadata,
|
||||
metadataUrl: '',
|
||||
@@ -20,6 +21,8 @@ export const sampleConfig: SamlPreferences = {
|
||||
loginLabel: 'SAML Login',
|
||||
wantAssertionsSigned: true,
|
||||
wantMessageSigned: true,
|
||||
signingPrivateKey: '',
|
||||
signingCertificate: '',
|
||||
signatureConfig: {
|
||||
prefix: 'ds',
|
||||
location: {
|
||||
|
||||
@@ -184,6 +184,9 @@
|
||||
"GET /settings/sso/saml": {
|
||||
"status": "gap"
|
||||
},
|
||||
"PUT /settings/sso/saml": {
|
||||
"status": "gap"
|
||||
},
|
||||
"GET /settings/log-streaming/event-types": {
|
||||
"status": "gap"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user