0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat: support SAML app encryption and nameIdFormat config (#6912)

This commit is contained in:
Darcy Ye 2025-01-06 18:47:42 +08:00 committed by GitHub
parent 580ed25ad7
commit 4191828fcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 166 additions and 6 deletions

View file

@ -128,7 +128,10 @@ export const ensembleSamlApplication = ({
samlConfig,
}: {
application: Application;
samlConfig: Pick<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'>;
samlConfig: Pick<
SamlApplicationConfig,
'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat'
>;
}): SamlApplicationResponse => {
return {
...application,

View file

@ -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);

View file

@ -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;

View file

@ -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`'
);
}
});
});

View file

@ -21,3 +21,35 @@ export const samlAcsUrlGuard = z.object({
binding: z.nativeEnum(BindingType),
url: z.string().url(),
}) satisfies ToZodObject<SamlAcsUrl>;
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<typeof samlEncryptionGuard>;
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);

View file

@ -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<typeof samlApplicationCreateGuard>;
export type CreateSamlApplication = z.input<typeof samlApplicationCreateGuard>;
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<typeof samlApplicationPatchGuard>;
@ -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<typeof samlApplicationResponseGuard>;

View file

@ -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'))