Skip to content
Open
14 changes: 14 additions & 0 deletions .changeset/quiet-owls-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@logto/console": minor
"@logto/core": minor
"@logto/phrases": minor
"@logto/schemas": minor
---

add authentication policies for SAML applications

SAML applications force fresh authentication by default, as before. To let a SAML application reuse an existing Logto session, turn off "Always force authentication" in the application settings, or set `authnRequestConfig.forceAuthn` to `false` using the SAML application Management API. The service provider can still require fresh authentication for a single sign-in with `ForceAuthn="true"` (SAML 2.0 core, section 3.4.1).

SAML assertions report the actual authentication time.

To require signed authentication requests, set `authnRequestConfig.requireSignedAuthnRequests` to `true` and provide the service provider’s PEM-encoded RSA X.509 certificate in `authnRequestConfig.signingCertificate`. Both HTTP-POST and HTTP-Redirect signatures are verified. Unsigned requests remain accepted by default.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type SamlApplicationFormData = Pick<
encryptSamlAssertion: boolean;
encryptThenSignSamlAssertion: boolean;
certificate?: string;
forceAuthn: boolean;
};

type Props = {
Expand Down Expand Up @@ -121,7 +122,10 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
return;
}

const { id, payload } = parseFormDataToSamlApplicationRequest(formData);
const { id, payload } = parseFormDataToSamlApplicationRequest(
formData,
data.authnRequestConfig
);

const updated = await api
.patch(`api/saml-applications/${id}`, { json: payload })
Expand Down Expand Up @@ -353,6 +357,17 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
)}
/>
</FormField>
<FormField
title="application_details.saml_idp_authentication.always_force_authn"
tip={t('application_details.saml_idp_authentication.always_force_authn_tip')}
>
<Switch
label={t(
'application_details.saml_idp_authentication.always_force_authn_description'
)}
{...register('forceAuthn')}
/>
</FormField>
<FormField title="application_details.saml_encryption_config.encrypt_assertion">
<Switch
label={t('application_details.saml_encryption_config.encrypt_assertion_description')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ApplicationType, NameIdFormat, type SamlApplicationResponse } from '@logto/schemas';

import {
parseFormDataToSamlApplicationRequest,
parseSamlApplicationResponseToFormData,
} from './utils';

const buildResponse = (
authnRequestConfig: SamlApplicationResponse['authnRequestConfig']
): SamlApplicationResponse =>
({
id: 'saml-app',
name: 'SAML app',
description: null,
type: ApplicationType.SAML,
entityId: 'https://sp.example.com',
acsUrl: null,
nameIdFormat: NameIdFormat.Persistent,
encryption: null,
attributeMapping: {},
authnRequestConfig,
}) as unknown as SamlApplicationResponse;

describe('SAML application authentication policy form mapping', () => {
it.each([
{ authnRequestConfig: null, expected: true },
{ authnRequestConfig: {}, expected: true },
{ authnRequestConfig: { forceAuthn: true }, expected: true },
{ authnRequestConfig: { forceAuthn: false }, expected: false },
])('shows forced authentication for %j', ({ authnRequestConfig, expected }) => {
expect(
parseSamlApplicationResponseToFormData(buildResponse(authnRequestConfig)).forceAuthn
).toBe(expected);
});

it('keeps the request signature settings when saving the toggle', () => {
const authnRequestConfig = {
requireSignedAuthnRequests: true,
signingCertificate: 'certificate',
};
const formData = {
...parseSamlApplicationResponseToFormData(buildResponse(authnRequestConfig)),
forceAuthn: false,
};

expect(
parseFormDataToSamlApplicationRequest(formData, authnRequestConfig).payload.authnRequestConfig
).toEqual({ ...authnRequestConfig, forceAuthn: false });
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
BindingType,
isSamlForceAuthnEnabled,
type PatchSamlApplication,
type SamlApplicationResponse,
} from '@logto/schemas';
Expand All @@ -10,7 +11,8 @@ import { type SamlApplicationFormData } from './Settings';
export const parseSamlApplicationResponseToFormData = (
data: SamlApplicationResponse
): SamlApplicationFormData => {
const { id, description, name, entityId, acsUrl, encryption, nameIdFormat } = data;
const { id, description, name, entityId, acsUrl, encryption, nameIdFormat, authnRequestConfig } =
data;

return {
id,
Expand All @@ -22,11 +24,14 @@ export const parseSamlApplicationResponseToFormData = (
encryptSamlAssertion: encryption?.encryptAssertion ?? false,
encryptThenSignSamlAssertion: encryption?.encryptThenSign ?? false,
certificate: encryption?.certificate ?? '',
forceAuthn: isSamlForceAuthnEnabled(authnRequestConfig),
};
};

export const parseFormDataToSamlApplicationRequest = (
data: SamlApplicationFormData
data: SamlApplicationFormData,
/** PATCH replaces `authnRequestConfig`, so settings the form does not edit are carried over. */
authnRequestConfig: SamlApplicationResponse['authnRequestConfig']
): {
id: string;
payload: PatchSamlApplication;
Expand All @@ -41,6 +46,7 @@ export const parseFormDataToSamlApplicationRequest = (
encryptThenSignSamlAssertion,
certificate,
nameIdFormat,
forceAuthn,
} = data;

// If acsUrl value is empty string, it should be removed. Convert it to null.
Expand All @@ -54,6 +60,7 @@ export const parseFormDataToSamlApplicationRequest = (
entityId,
acsUrl: acsUrlData,
nameIdFormat,
authnRequestConfig: { ...authnRequestConfig, forceAuthn },
...cond(
encryptSamlAssertion
? cond(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const mockSamlConfig = {
},
attributeMapping: {},
encryption: {},
authnRequestConfig: null,
nameIdFormat: NameIdFormat.Persistent,
};

Expand All @@ -47,6 +48,31 @@ const createLibrary = () =>
);

describe('createSamlApplicationsLibrary()', () => {
it('rejects an invalid signing certificate before updating any records', async () => {
await expect(
createLibrary().updateSamlApplicationById('foo', {
name: 'new name',
authnRequestConfig: { requireSignedAuthnRequests: true, signingCertificate: 'invalid' },
})
).rejects.toThrow();
expect(updateSamlApplicationConfig).not.toHaveBeenCalled();
expect(updateApplicationById).not.toHaveBeenCalled();
});

it.each([{ forceAuthn: true }, null])(
'persists authentication policy: %j',
async (authnRequestConfig) => {
const result = await createLibrary().updateSamlApplicationById('foo', { authnRequestConfig });
expect(updateSamlApplicationConfig).toHaveBeenCalledWith({
set: { ...mockSamlConfig, authnRequestConfig },
where: { applicationId: 'foo' },
jsonbMode: 'replace',
});
expect(result.authnRequestConfig).toEqual(authnRequestConfig);
expect(updateApplicationById).not.toHaveBeenCalled();
}
);

afterEach(() => {
jest.clearAllMocks();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

import { assembleSamlApplication, generateKeyPairAndCertificate } from './utils.js';
import {
assembleSamlApplication,
generateKeyPairAndCertificate,
validateSamlAuthnRequestConfig,
} from './utils.js';

const consoleLog = new ConsoleLog(chalk.magenta('SAML app custom domain'));

Expand Down Expand Up @@ -106,6 +110,8 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
// Can not put this in a single Promise.all with `findApplicationById()` we want to API to throw SAML app only error before throwing other errors.
const originalAppConfig = await findSamlApplicationConfigByApplicationId(id);

validateSamlAuthnRequestConfig(config.authnRequestConfig);

const [updatedApplication, upToDateSamlConfig] = await Promise.all([
Object.keys(applicationData).length > 0
? updateApplicationById(id, applicationData)
Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/libraries/saml-application/utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import crypto from 'node:crypto';

import {
type SamlAuthnRequestConfig,
type SamlApplicationResponse,
type Application,
type SamlApplicationConfig,
type SamlAcsUrl,
BindingType,
type CertificateFingerprints,
} from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { appendPath, trySafe, type Nullable } from '@silverhand/essentials';
import { addYears } from 'date-fns';
import forge from 'node-forge';
import { z } from 'zod';
Expand Down Expand Up @@ -123,7 +124,12 @@ export const assembleSamlApplication = ({
application: Application;
samlConfig: Pick<
SamlApplicationConfig,
'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat'
| 'attributeMapping'
| 'entityId'
| 'acsUrl'
| 'encryption'
| 'nameIdFormat'
| 'authnRequestConfig'
>;
}): SamlApplicationResponse => {
return {
Expand Down Expand Up @@ -151,3 +157,17 @@ export const buildSingleSignOnUrl = (baseUrl: URL, samlApplicationId: string) =>

export const buildSamlIdentityProviderEntityId = (baseUrl: URL, samlApplicationId: string) =>
appendPath(baseUrl, `saml/${samlApplicationId}`).toString();

/** Validate the SP trust certificate before persisting request-signature policy. */
export const validateSamlAuthnRequestConfig = (config?: Nullable<SamlAuthnRequestConfig>) => {
const signingCertificate = config?.signingCertificate;
if (!signingCertificate) {
return;
}

const certificate = trySafe(() => new crypto.X509Certificate(signingCertificate));
assertThat(
certificate?.publicKey.asymmetricKeyType === 'rsa',
'application.saml.invalid_certificate_pem_format'
);
};
9 changes: 9 additions & 0 deletions packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import {
} from './utils.js';

describe('getConstantClientMetadata()', () => {
it('requires auth_time only for SAML clients', () => {
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SAML).require_auth_time).toBe(
true
);
expect(
getConstantClientMetadata(mockEnvSet, ApplicationType.Traditional).require_auth_time
).toBeUndefined();
});

it('should return correct metadata for SPA', () => {
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SPA)).toMatchObject({
application_type: 'web',
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const getConstantClientMetadata = (
userinfo_signed_response_alg: jwkSigningAlg,
id_token_signed_response_alg: jwkSigningAlg,
introspection_signed_response_alg: jwkSigningAlg,
...conditional(type === ApplicationType.SAML && { require_auth_time: true }),
};

/**
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/queries/saml-application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export type SamlApplicationDetails = Pick<
> &
Pick<
SamlApplicationConfig,
'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat'
| 'attributeMapping'
| 'entityId'
| 'acsUrl'
| 'encryption'
| 'nameIdFormat'
| 'authnRequestConfig'
> &
NullableObject<SamlApplicationSecretDetails>;

Expand All @@ -58,6 +63,7 @@ const samlApplicationDetailsGuard = Applications.guard
acsUrl: true,
nameIdFormat: true,
encryption: true,
authnRequestConfig: true,
})
)
.merge(
Expand All @@ -73,7 +79,7 @@ const samlApplicationDetailsGuard = Applications.guard
export const createSamlApplicationQueries = (pool: CommonQueryMethods) => {
const getSamlApplicationDetailsById = async (id: string): Promise<SamlApplicationDetails> => {
const result = await pool.maybeOne(sql`
select ${fields.id} as id, ${fields.secret} as secret, ${fields.name} as name, ${fields.description} as description, ${fields.customData} as custom_data, ${fields.oidcClientMetadata} as oidc_client_metadata, ${samlApplicationConfigsFields.attributeMapping} as attribute_mapping, ${samlApplicationConfigsFields.entityId} as entity_id, ${samlApplicationConfigsFields.acsUrl} as acs_url, ${samlApplicationConfigsFields.encryption} as encryption, ${samlApplicationConfigsFields.nameIdFormat} as name_id_format, ${samlApplicationSecretsFields.privateKey} as private_key, ${samlApplicationSecretsFields.certificate} as certificate, ${samlApplicationSecretsFields.active} as active, ${samlApplicationSecretsFields.expiresAt} as expires_at
select ${fields.id} as id, ${fields.secret} as secret, ${fields.name} as name, ${fields.description} as description, ${fields.customData} as custom_data, ${fields.oidcClientMetadata} as oidc_client_metadata, ${samlApplicationConfigsFields.attributeMapping} as attribute_mapping, ${samlApplicationConfigsFields.entityId} as entity_id, ${samlApplicationConfigsFields.acsUrl} as acs_url, ${samlApplicationConfigsFields.encryption} as encryption, ${samlApplicationConfigsFields.authnRequestConfig} as authn_request_config, ${samlApplicationConfigsFields.nameIdFormat} as name_id_format, ${samlApplicationSecretsFields.privateKey} as private_key, ${samlApplicationSecretsFields.certificate} as certificate, ${samlApplicationSecretsFields.active} as active, ${samlApplicationSecretsFields.expiresAt} as expires_at
from ${table}
left join ${samlApplicationConfigsTable} on ${fields.id}=${samlApplicationConfigsFields.applicationId}
left join ${samlApplicationSecretsTable} on ${fields.id}=${samlApplicationSecretsFields.applicationId}
Expand Down
Loading
Loading