Skip to content

Commit

Permalink
feat(core): support configuration on nameIdFormat and encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Jan 8, 2025
1 parent 36244fa commit 8178b64
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -322,15 +322,14 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
<Select
options={Object.values(NameIdFormat).map((format) => ({
value: format,
// eslint-disable-next-line no-restricted-syntax
title: (
<span>
{t(nameIdFormatToOptionMap[format])}
<span className={styles.nameIdFormatDescription}>
({t(nameIdFormatToOptionDescriptionMap[format])})
</span>
</span>
) as React.ReactNode,
),
}))}
value={value}
onChange={onChange}
Expand Down Expand Up @@ -363,7 +362,6 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
error={errors.certificate?.message}
{...register('certificate', {
validate: (value) => {
console.log('certificate value', value, !value);
if (!value) {
return t(
'application_details.saml_encryption_config.certificate_missing_error'
Expand Down
61 changes: 44 additions & 17 deletions packages/core/src/saml-applications/SamlApplication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
// TODO: refactor this file to reduce LOC
import { parseJson } from '@logto/connector-kit';
import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
import { type SamlAcsUrl, BindingType } from '@logto/schemas';
import {
type SamlAcsUrl,
BindingType,
type NameIdFormat,
type SamlEncryption,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat, appendPath, deduplicate } from '@silverhand/essentials';
import { tryThat, appendPath, deduplicate, type Nullable, cond } from '@silverhand/essentials';
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
import { XMLValidator } from 'fast-xml-parser';
import saml from 'samlify';
Expand Down Expand Up @@ -41,6 +46,23 @@ type ValidSamlApplicationDetails = {
redirectUri: string;
privateKey: string;
certificate: string;
nameIdFormat: NameIdFormat;
encryption: Nullable<SamlEncryption>;
};

type SamlIdentityProviderConfig = {
entityId: string;
certificate: string;
singleSignOnUrl: string;
privateKey: string;
nameIdFormat: NameIdFormat;
encryptSamlAssertion: boolean;
};

type SamlServiceProviderConfig = {
entityId: string;
acsUrl: SamlAcsUrl;
certificate?: string;
};

// Used to check whether xml content is valid in format.
Expand Down Expand Up @@ -68,6 +90,8 @@ const validateSamlApplicationDetails = (
privateKey,
certificate,
secret,
nameIdFormat,
encryption,
} = details;

assertThat(acsUrl, 'application.saml.acs_url_required');
Expand All @@ -84,6 +108,8 @@ const validateSamlApplicationDetails = (
redirectUri: redirectUris[0],
privateKey,
certificate,
nameIdFormat,
encryption,
};
};

Expand Down Expand Up @@ -112,12 +138,9 @@ const buildSamlIdentityProvider = ({
certificate,
singleSignOnUrl,
privateKey,
}: {
entityId: string;
certificate: string;
singleSignOnUrl: string;
privateKey: string;
}): saml.IdentityProviderInstance => {
nameIdFormat,
encryptSamlAssertion,
}: SamlIdentityProviderConfig): saml.IdentityProviderInstance => {
// eslint-disable-next-line new-cap
return saml.IdentityProvider({
entityID: entityId,
Expand All @@ -133,12 +156,9 @@ const buildSamlIdentityProvider = ({
},
],
privateKey,
isAssertionEncrypted: false,
isAssertionEncrypted: encryptSamlAssertion,
loginResponseTemplate: buildLoginResponseTemplate(),
nameIDFormat: [
saml.Constants.namespace.format.emailAddress,
saml.Constants.namespace.format.persistent,
],
nameIDFormat: [nameIdFormat],
});
};

Expand Down Expand Up @@ -193,10 +213,12 @@ export class SamlApplication {
}

public get sp(): saml.ServiceProviderInstance {
const { certificate: encryptCert, ...rest } = this.buildSpConfig();
this._sp ||= buildSamlServiceProvider({
...this.buildSpConfig(),
...rest,
certificate: this.details.certificate,
isWantAuthnRequestsSigned: this.idp.entityMeta.isWantAuthnRequestsSigned(),
...cond(encryptCert && { encryptCert }),
});
return this._sp;
}
Expand Down Expand Up @@ -234,7 +256,8 @@ export class SamlApplication {
null,
'post',
userInfo,
this.createSamlTemplateCallback(userInfo)
this.createSamlTemplateCallback(userInfo),
this.details.encryption?.encryptThenSign
);

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
Expand Down Expand Up @@ -360,6 +383,7 @@ export class SamlApplication {
);

const { nameIDFormat } = this.idp.entitySetting;
assertThat(nameIDFormat, 'application.saml.name_id_format_required');
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);

const id = `ID_${generateStandardId()}`;
Expand Down Expand Up @@ -406,19 +430,22 @@ export class SamlApplication {
};
};

private buildIdpConfig() {
private buildIdpConfig(): SamlIdentityProviderConfig {
return {
entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId),
privateKey: this.details.privateKey,
certificate: this.details.certificate,
singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId),
nameIdFormat: this.details.nameIdFormat,
encryptSamlAssertion: this.details.encryption?.encryptAssertion ?? false,
};
}

private buildSpConfig() {
private buildSpConfig(): SamlServiceProviderConfig {
return {
entityId: this.details.entityId,
acsUrl: this.details.acsUrl,
certificate: this.details.encryption?.certificate,
};
}
}
Expand Down
69 changes: 35 additions & 34 deletions packages/core/src/saml-applications/SamlApplication/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// TODO: refactor this file to reduce LOC
import saml from 'samlify';
import { generateStandardId } from '@logto/shared';

import RequestError from '#src/errors/RequestError/index.js';
import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
import assertThat from '#src/utils/assert-that.js';

import { NameIdFormat } from '#src/packages/schemas/lib/index.js';

/**
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
Expand All @@ -13,43 +16,41 @@ import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
*/
export const buildSamlAssertionNameId = (
user: IdTokenProfileStandardClaims,
idpNameIDFormat?: string | string[]
idpNameIDFormat: string[]
): { NameIDFormat: string; NameID: string } => {
if (idpNameIDFormat) {
// Get the first name ID format
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
// If email format is specified, try to use email first
if (
format === saml.Constants.namespace.format.emailAddress &&
user.email &&
user.email_verified
) {
return {
NameIDFormat: format,
NameID: user.email,
};
}
// For other formats or when email is not available, use sub
if (format === saml.Constants.namespace.format.persistent) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}
}
// No nameIDFormat specified, use default logic
// Use email if available
if (user.email && user.email_verified) {
// Get the first name ID format
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;

// If email format is specified, try to use email first
if (format === NameIdFormat.EmailAddress) {
assertThat(user.email, 'application.saml.missing_email_address');
assertThat(user.email_verified, 'application.saml.email_address_unverified');
return {
NameIDFormat: saml.Constants.namespace.format.emailAddress,
NameIDFormat: format,
NameID: user.email,
};
}
// Fallback to persistent format with user.sub
return {
NameIDFormat: saml.Constants.namespace.format.persistent,
NameID: user.sub,
};

// For persistent and unspecified formats, we use Logto user ID.
if (format === NameIdFormat.Persistent || format === NameIdFormat.Unspecified) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}

// For transient format, we generate a random ID.
if (format === NameIdFormat.Transient) {
return {
NameIDFormat: format,
NameID: generateStandardId(),
};
}

throw new RequestError({
code: 'application.saml.unsupported_name_id_format',
details: { idpNameIDFormat, user },
});
};

export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): string => {
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/saml-applications/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export type SamlApplicationDetails = Pick<
Application,
'id' | 'secret' | 'name' | 'description' | 'customData' | 'oidcClientMetadata'
> &
Pick<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'> &
Pick<
SamlApplicationConfig,
'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat'
> &
NullableObject<SamlApplicationSecretDetails>;

const samlApplicationDetailsGuard = Applications.guard
Expand All @@ -51,6 +54,8 @@ const samlApplicationDetailsGuard = Applications.guard
attributeMapping: true,
entityId: true,
acsUrl: true,
nameIdFormat: true,
encryption: true,
})
)
.merge(
Expand All @@ -66,7 +71,7 @@ const samlApplicationDetailsGuard = Applications.guard
export const createSamlApplicationQueries = (pool: CommonQueryMethods) => {
const getSamlApplicationDetailsById = async (id: string): Promise<SamlApplicationDetails> => {
const result = await pool.one(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, ${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.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
4 changes: 4 additions & 0 deletions packages/phrases/src/locales/en/errors/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const application = {
can_not_delete_active_secret: 'Can not delete the active secret.',
no_active_secret: 'No active secret found.',
entity_id_required: 'Entity ID is required to generate metadata.',
name_id_format_required: 'Name ID format is required.',
unsupported_name_id_format: 'Unsupported name ID format.',
missing_email_address: 'User does not have an email address.',
email_address_unverified: 'User email address is not verified.',
invalid_certificate_pem_format: 'Invalid PEM certificate format',
acs_url_required: 'Assertion Consumer Service URL is required.',
private_key_required: 'Private key is required.',
Expand Down

0 comments on commit 8178b64

Please sign in to comment.