mirror of
https://github.com/logto-io/logto.git
synced 2025-02-24 22:05:56 -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,
|
samlConfig,
|
||||||
}: {
|
}: {
|
||||||
application: Application;
|
application: Application;
|
||||||
samlConfig: Pick<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'>;
|
samlConfig: Pick<
|
||||||
|
SamlApplicationConfig,
|
||||||
|
'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat'
|
||||||
|
>;
|
||||||
}): SamlApplicationResponse => {
|
}): SamlApplicationResponse => {
|
||||||
return {
|
return {
|
||||||
...application,
|
...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 { createApplication, deleteApplication, updateApplication } from '#src/api/application.js';
|
||||||
import {
|
import {
|
||||||
|
@ -24,6 +24,8 @@ describe('SAML application', () => {
|
||||||
description: 'test',
|
description: 'test',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(createdSamlApplication.nameIdFormat).toBe(NameIdFormat.Persistent);
|
||||||
|
|
||||||
await deleteSamlApplication(createdSamlApplication.id);
|
await deleteSamlApplication(createdSamlApplication.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -51,14 +53,25 @@ describe('SAML application', () => {
|
||||||
binding: BindingType.Post,
|
binding: BindingType.Post,
|
||||||
url: 'https://example.logto.io/sso/saml',
|
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({
|
const createdSamlApplication = await createSamlApplication({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createdSamlApplication.entityId).toEqual(config.entityId);
|
expect(createdSamlApplication.entityId).toEqual(config.entityId);
|
||||||
expect(createdSamlApplication.acsUrl).toEqual(config.acsUrl);
|
expect(createdSamlApplication.acsUrl).toEqual(config.acsUrl);
|
||||||
|
expect(createdSamlApplication.nameIdFormat).toEqual(config.nameIdFormat);
|
||||||
|
expect(createdSamlApplication.encryption).toEqual(config.encryption);
|
||||||
|
|
||||||
await deleteSamlApplication(createdSamlApplication.id);
|
await deleteSamlApplication(createdSamlApplication.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -71,6 +84,8 @@ describe('SAML application', () => {
|
||||||
expect(createdSamlApplication.entityId).toEqual('http://example.logto.io/foo');
|
expect(createdSamlApplication.entityId).toEqual('http://example.logto.io/foo');
|
||||||
expect(createdSamlApplication.acsUrl).toEqual(null);
|
expect(createdSamlApplication.acsUrl).toEqual(null);
|
||||||
expect(createdSamlApplication.attributeMapping).toEqual({});
|
expect(createdSamlApplication.attributeMapping).toEqual({});
|
||||||
|
expect(createdSamlApplication.nameIdFormat).toEqual(NameIdFormat.Persistent);
|
||||||
|
expect(createdSamlApplication.encryption).toBe(null);
|
||||||
|
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
acsUrl: {
|
acsUrl: {
|
||||||
|
@ -78,6 +93,10 @@ describe('SAML application', () => {
|
||||||
url: 'https://example.logto.io/sso/saml',
|
url: 'https://example.logto.io/sso/saml',
|
||||||
},
|
},
|
||||||
entityId: null,
|
entityId: null,
|
||||||
|
nameIdFormat: NameIdFormat.EmailAddress,
|
||||||
|
encryption: {
|
||||||
|
encryptAssertion: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, {
|
const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, {
|
||||||
name: 'updated',
|
name: 'updated',
|
||||||
|
@ -86,6 +105,8 @@ describe('SAML application', () => {
|
||||||
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);
|
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);
|
||||||
expect(updatedSamlApplication.entityId).toEqual(newConfig.entityId);
|
expect(updatedSamlApplication.entityId).toEqual(newConfig.entityId);
|
||||||
expect(updatedSamlApplication.attributeMapping).toEqual({});
|
expect(updatedSamlApplication.attributeMapping).toEqual({});
|
||||||
|
expect(updatedSamlApplication.nameIdFormat).toEqual(newConfig.nameIdFormat);
|
||||||
|
expect(updatedSamlApplication.encryption).toEqual(newConfig.encryption);
|
||||||
|
|
||||||
const upToDateSamlApplication = await getSamlApplication(createdSamlApplication.id);
|
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),
|
binding: z.nativeEnum(BindingType),
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
}) satisfies ToZodObject<SamlAcsUrl>;
|
}) 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 { Applications } from '../db-entries/application.js';
|
||||||
import { SamlApplicationConfigs } from '../db-entries/saml-application-config.js';
|
import { SamlApplicationConfigs } from '../db-entries/saml-application-config.js';
|
||||||
import { SamlApplicationSecrets } from '../db-entries/saml-application-secret.js';
|
import { SamlApplicationSecrets } from '../db-entries/saml-application-secret.js';
|
||||||
|
import { nameIdFormatGuard, NameIdFormat } from '../foundations/index.js';
|
||||||
|
|
||||||
import { applicationCreateGuard, applicationPatchGuard } from './application.js';
|
import { applicationCreateGuard, applicationPatchGuard } from './application.js';
|
||||||
|
|
||||||
|
@ -11,6 +12,8 @@ const samlAppConfigGuard = SamlApplicationConfigs.guard.pick({
|
||||||
attributeMapping: true,
|
attributeMapping: true,
|
||||||
entityId: true,
|
entityId: true,
|
||||||
acsUrl: true,
|
acsUrl: true,
|
||||||
|
encryption: true,
|
||||||
|
nameIdFormat: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const samlApplicationCreateGuard = applicationCreateGuard
|
export const samlApplicationCreateGuard = applicationCreateGuard
|
||||||
|
@ -20,9 +23,10 @@ export const samlApplicationCreateGuard = applicationCreateGuard
|
||||||
customData: true,
|
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.
|
// 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
|
export const samlApplicationPatchGuard = applicationPatchGuard
|
||||||
.pick({
|
.pick({
|
||||||
|
@ -31,7 +35,8 @@ export const samlApplicationPatchGuard = applicationPatchGuard
|
||||||
customData: true,
|
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.
|
// 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>;
|
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.
|
// 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.
|
// When starting to create a SAML application, SAML configuration is optional, which can lead to the absence of SAML configuration.
|
||||||
samlAppConfigGuard
|
samlAppConfigGuard
|
||||||
);
|
)
|
||||||
|
.extend({ nameIdFormat: nameIdFormatGuard });
|
||||||
|
|
||||||
export type SamlApplicationResponse = z.infer<typeof samlApplicationResponseGuard>;
|
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,
|
attribute_mapping jsonb /* @use SamlAttributeMapping */ not null default '{}'::jsonb,
|
||||||
entity_id varchar(128),
|
entity_id varchar(128),
|
||||||
acs_url jsonb /* @use SamlAcsUrl */,
|
acs_url jsonb /* @use SamlAcsUrl */,
|
||||||
|
encryption jsonb /* @use SamlEncryption */,
|
||||||
|
name_id_format varchar(128) /* @use NameIdFormat */ not null,
|
||||||
primary key (tenant_id, application_id),
|
primary key (tenant_id, application_id),
|
||||||
constraint saml_application_configs__application_type
|
constraint saml_application_configs__application_type
|
||||||
check (check_application_type(application_id, 'SAML'))
|
check (check_application_type(application_id, 'SAML'))
|
||||||
|
|
Loading…
Add table
Reference in a new issue