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:
parent
580ed25ad7
commit
4191828fcb
7 changed files with 166 additions and 6 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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`'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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'))
|
||||
|
|
Loading…
Add table
Reference in a new issue