diff --git a/packages/core/src/saml-applications/libraries/utils.ts b/packages/core/src/saml-applications/libraries/utils.ts index 003ac52bb..d86ddbb8d 100644 --- a/packages/core/src/saml-applications/libraries/utils.ts +++ b/packages/core/src/saml-applications/libraries/utils.ts @@ -128,7 +128,10 @@ export const ensembleSamlApplication = ({ samlConfig, }: { application: Application; - samlConfig: Pick; + samlConfig: Pick< + SamlApplicationConfig, + 'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat' + >; }): SamlApplicationResponse => { return { ...application, diff --git a/packages/integration-tests/src/tests/api/application/saml-application.test.ts b/packages/integration-tests/src/tests/api/application/saml-application.test.ts index d1f8c565b..8d4132b8d 100644 --- a/packages/integration-tests/src/tests/api/application/saml-application.test.ts +++ b/packages/integration-tests/src/tests/api/application/saml-application.test.ts @@ -1,4 +1,4 @@ -import { ApplicationType, BindingType } from '@logto/schemas'; +import { ApplicationType, BindingType, NameIdFormat } from '@logto/schemas'; import { createApplication, deleteApplication, updateApplication } from '#src/api/application.js'; import { @@ -24,6 +24,8 @@ describe('SAML application', () => { description: 'test', }); + expect(createdSamlApplication.nameIdFormat).toBe(NameIdFormat.Persistent); + await deleteSamlApplication(createdSamlApplication.id); }); @@ -51,14 +53,25 @@ describe('SAML application', () => { binding: BindingType.Post, url: 'https://example.logto.io/sso/saml', }, + nameIdFormat: NameIdFormat.EmailAddress, + encryption: { + encryptAssertion: true, + certificate: + '-----BEGIN CERTIFICATE-----\nMIIDDTCCAfWgAwIBAgI...\n-----END CERTIFICATE-----\n', + encryptThenSign: false, + }, }; const createdSamlApplication = await createSamlApplication({ name: 'test', description: 'test', ...config, }); + expect(createdSamlApplication.entityId).toEqual(config.entityId); expect(createdSamlApplication.acsUrl).toEqual(config.acsUrl); + expect(createdSamlApplication.nameIdFormat).toEqual(config.nameIdFormat); + expect(createdSamlApplication.encryption).toEqual(config.encryption); + await deleteSamlApplication(createdSamlApplication.id); }); @@ -71,6 +84,8 @@ describe('SAML application', () => { expect(createdSamlApplication.entityId).toEqual('http://example.logto.io/foo'); expect(createdSamlApplication.acsUrl).toEqual(null); expect(createdSamlApplication.attributeMapping).toEqual({}); + expect(createdSamlApplication.nameIdFormat).toEqual(NameIdFormat.Persistent); + expect(createdSamlApplication.encryption).toBe(null); const newConfig = { acsUrl: { @@ -78,6 +93,10 @@ describe('SAML application', () => { url: 'https://example.logto.io/sso/saml', }, entityId: null, + nameIdFormat: NameIdFormat.EmailAddress, + encryption: { + encryptAssertion: false, + }, }; const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, { name: 'updated', @@ -86,6 +105,8 @@ describe('SAML application', () => { expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl); expect(updatedSamlApplication.entityId).toEqual(newConfig.entityId); expect(updatedSamlApplication.attributeMapping).toEqual({}); + expect(updatedSamlApplication.nameIdFormat).toEqual(newConfig.nameIdFormat); + expect(updatedSamlApplication.encryption).toEqual(newConfig.encryption); const upToDateSamlApplication = await getSamlApplication(createdSamlApplication.id); diff --git a/packages/schemas/alterations/next-1735274337-add-encryption-config-to-saml-apps.ts b/packages/schemas/alterations/next-1735274337-add-encryption-config-to-saml-apps.ts new file mode 100644 index 000000000..5b79a67eb --- /dev/null +++ b/packages/schemas/alterations/next-1735274337-add-encryption-config-to-saml-apps.ts @@ -0,0 +1,35 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +enum NameIdFormat { + /** Uses unique and persistent identifiers for the user. */ + Persistent = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', +} + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + alter table saml_application_configs + add column encryption jsonb, + add column name_id_format varchar(128); + `); + await pool.query(sql` + update saml_application_configs + set name_id_format = ${NameIdFormat.Persistent}; + `); + await pool.query(sql` + alter table saml_application_configs + alter column name_id_format set not null; + `); + }, + down: async (pool) => { + await pool.query(sql` + alter table saml_application_configs + drop column encryption, + drop column name_id_format; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types/saml-application-configs.test.ts b/packages/schemas/src/foundations/jsonb-types/saml-application-configs.test.ts new file mode 100644 index 000000000..139d59043 --- /dev/null +++ b/packages/schemas/src/foundations/jsonb-types/saml-application-configs.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; + +import { samlEncryptionGuard } from './saml-application-configs.js'; + +describe('samlEncryptionGuard', () => { + // Test valid configurations + it('should pass when encryption is disabled', () => { + const result = samlEncryptionGuard.safeParse({ + encryptAssertion: false, + }); + expect(result.success).toBe(true); + }); + + it('should pass when encryption is enabled with all required fields', () => { + const result = samlEncryptionGuard.safeParse({ + encryptAssertion: true, + encryptThenSign: true, + certificate: '-----BEGIN CERTIFICATE-----\nMIICYDCCAcmgAwIBA...', + }); + expect(result.success).toBe(true); + }); + + // Test invalid configurations + it('should fail when encryptAssertion is true but missing encryptThenSign', () => { + const result = samlEncryptionGuard.safeParse({ + encryptAssertion: true, + certificate: '-----BEGIN CERTIFICATE-----\nMIICYDCCAcmgAwIBA...', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + '`encryptThenSign` and `certificate` are required when `encryptAssertion` is `true`' + ); + } + }); + + it('should fail when encryptAssertion is true but missing certificate', () => { + const result = samlEncryptionGuard.safeParse({ + encryptAssertion: true, + encryptThenSign: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + '`encryptThenSign` and `certificate` are required when `encryptAssertion` is `true`' + ); + } + }); + + it('should fail when encryptAssertion is true but missing both encryptThenSign and certificate', () => { + const result = samlEncryptionGuard.safeParse({ + encryptAssertion: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + '`encryptThenSign` and `certificate` are required when `encryptAssertion` is `true`' + ); + } + }); +}); diff --git a/packages/schemas/src/foundations/jsonb-types/saml-application-configs.ts b/packages/schemas/src/foundations/jsonb-types/saml-application-configs.ts index a2df45102..de3f44f1c 100644 --- a/packages/schemas/src/foundations/jsonb-types/saml-application-configs.ts +++ b/packages/schemas/src/foundations/jsonb-types/saml-application-configs.ts @@ -21,3 +21,35 @@ export const samlAcsUrlGuard = z.object({ binding: z.nativeEnum(BindingType), url: z.string().url(), }) satisfies ToZodObject; + +export const samlEncryptionGuard = z + .object({ + encryptAssertion: z.boolean().optional(), + encryptThenSign: z.boolean().optional(), + certificate: z.string().optional(), + }) + .superRefine(({ encryptAssertion, encryptThenSign, certificate }, ctx) => { + if (encryptAssertion && (encryptThenSign === undefined || certificate === undefined)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + '`encryptThenSign` and `certificate` are required when `encryptAssertion` is `true`', + }); + return z.NEVER; + } + }); + +export type SamlEncryption = z.input; + +export enum NameIdFormat { + /** The Identity Provider can determine the format. */ + Unspecified = 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', + /** Returns the email address of the user. */ + EmailAddress = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + /** Uses unique and persistent identifiers for the user. */ + Persistent = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + /** Uses unique and transient identifiers for the user, which can be different for each session. */ + Transient = 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', +} + +export const nameIdFormatGuard = z.nativeEnum(NameIdFormat); diff --git a/packages/schemas/src/types/saml-application.ts b/packages/schemas/src/types/saml-application.ts index dce5cf3df..62b0b91d0 100644 --- a/packages/schemas/src/types/saml-application.ts +++ b/packages/schemas/src/types/saml-application.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { Applications } from '../db-entries/application.js'; import { SamlApplicationConfigs } from '../db-entries/saml-application-config.js'; import { SamlApplicationSecrets } from '../db-entries/saml-application-secret.js'; +import { nameIdFormatGuard, NameIdFormat } from '../foundations/index.js'; import { applicationCreateGuard, applicationPatchGuard } from './application.js'; @@ -11,6 +12,8 @@ const samlAppConfigGuard = SamlApplicationConfigs.guard.pick({ attributeMapping: true, entityId: true, acsUrl: true, + encryption: true, + nameIdFormat: true, }); export const samlApplicationCreateGuard = applicationCreateGuard @@ -20,9 +23,10 @@ export const samlApplicationCreateGuard = applicationCreateGuard customData: true, }) // The reason for encapsulating attributeMapping and spMetadata into an object within the config field is that you cannot provide only one of `attributeMapping` or `spMetadata`. Due to the structure of the `saml_application_configs` table, both must be not null. - .merge(samlAppConfigGuard.partial()); + .merge(samlAppConfigGuard.partial()) + .extend({ nameIdFormat: nameIdFormatGuard.optional().default(NameIdFormat.Persistent) }); -export type CreateSamlApplication = z.infer; +export type CreateSamlApplication = z.input; export const samlApplicationPatchGuard = applicationPatchGuard .pick({ @@ -31,7 +35,8 @@ export const samlApplicationPatchGuard = applicationPatchGuard customData: true, }) // The reason for encapsulating attributeMapping and spMetadata into an object within the config field is that you cannot provide only one of `attributeMapping` or `spMetadata`. Due to the structure of the `saml_application_configs` table, both must be not null. - .merge(samlAppConfigGuard.partial()); + .merge(samlAppConfigGuard.partial()) + .extend({ nameIdFormat: nameIdFormatGuard.optional() }); export type PatchSamlApplication = z.infer; @@ -46,7 +51,8 @@ export const samlApplicationResponseGuard = Applications.guard // Partial to allow the optional fields to be omitted in the response. // When starting to create a SAML application, SAML configuration is optional, which can lead to the absence of SAML configuration. samlAppConfigGuard - ); + ) + .extend({ nameIdFormat: nameIdFormatGuard }); export type SamlApplicationResponse = z.infer; diff --git a/packages/schemas/tables/saml_application_configs.sql b/packages/schemas/tables/saml_application_configs.sql index a8d5ec895..809e4203a 100644 --- a/packages/schemas/tables/saml_application_configs.sql +++ b/packages/schemas/tables/saml_application_configs.sql @@ -9,6 +9,8 @@ create table saml_application_configs ( attribute_mapping jsonb /* @use SamlAttributeMapping */ not null default '{}'::jsonb, entity_id varchar(128), acs_url jsonb /* @use SamlAcsUrl */, + encryption jsonb /* @use SamlEncryption */, + name_id_format varchar(128) /* @use NameIdFormat */ not null, primary key (tenant_id, application_id), constraint saml_application_configs__application_type check (check_application_type(application_id, 'SAML'))