mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
feat(console,core): enable configure on nameIdFormat and encryption (#6929)
* feat(console): enable configure on nameIdFormat and encryption * feat(core): support configuration on nameIdFormat and encryption
This commit is contained in:
parent
39cef8ea47
commit
b335ad01b1
12 changed files with 314 additions and 66 deletions
|
@ -39,3 +39,14 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-error);
|
||||
margin-top: _.unit(1);
|
||||
|
||||
a {
|
||||
color: var(--color-error);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,9 +14,14 @@ function Textarea(
|
|||
reference: ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
return (
|
||||
<div className={classNames(styles.container, Boolean(error) && styles.error, className)}>
|
||||
<textarea {...rest} ref={reference} />
|
||||
</div>
|
||||
<>
|
||||
<div className={classNames(styles.container, Boolean(error) && styles.error, className)}>
|
||||
<textarea {...rest} ref={reference} />
|
||||
</div>
|
||||
{Boolean(error) && typeof error !== 'boolean' && (
|
||||
<div className={styles.errorMessage}>{error}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { type SamlApplicationSecretResponse, type SamlApplicationResponse } from '@logto/schemas';
|
||||
/* eslint-disable max-lines */
|
||||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import {
|
||||
type SamlApplicationSecretResponse,
|
||||
type SamlApplicationResponse,
|
||||
NameIdFormat,
|
||||
} from '@logto/schemas';
|
||||
import { appendPath, type Nullable } from '@silverhand/essentials';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR, { type KeyedMutator } from 'swr';
|
||||
|
@ -15,8 +21,11 @@ import { AppDataContext } from '@/contexts/AppDataProvider';
|
|||
import Button from '@/ds-components/Button';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import Select from '@/ds-components/Select';
|
||||
import Switch from '@/ds-components/Switch';
|
||||
import Table from '@/ds-components/Table';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import Textarea from '@/ds-components/Textarea';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
@ -32,15 +41,19 @@ import {
|
|||
samlApplicationManagementApiPrefix,
|
||||
samlApplicationMetadataEndpointSuffix,
|
||||
samlApplicationSingleSignOnEndpointSuffix,
|
||||
validateCertificate,
|
||||
} from './utils';
|
||||
|
||||
export type SamlApplicationFormData = Pick<
|
||||
SamlApplicationResponse,
|
||||
'id' | 'description' | 'name' | 'entityId'
|
||||
'id' | 'description' | 'name' | 'entityId' | 'nameIdFormat'
|
||||
> & {
|
||||
// Currently we only support HTTP-POST binding
|
||||
// Keep the acsUrl as a string in the form data instead of the object
|
||||
acsUrl: Nullable<string>;
|
||||
encryptSamlAssertion: boolean;
|
||||
encryptThenSignSamlAssertion: boolean;
|
||||
certificate?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
@ -49,6 +62,25 @@ type Props = {
|
|||
readonly isDeleted: boolean;
|
||||
};
|
||||
|
||||
type NameIdFormatToTranslationKey = {
|
||||
[key in NameIdFormat]: AdminConsoleKey;
|
||||
};
|
||||
|
||||
const nameIdFormatToOptionMap = Object.freeze({
|
||||
[NameIdFormat.EmailAddress]: 'application_details.saml_idp_name_id_format.email_address',
|
||||
[NameIdFormat.Transient]: 'application_details.saml_idp_name_id_format.transient',
|
||||
[NameIdFormat.Persistent]: 'application_details.saml_idp_name_id_format.persistent',
|
||||
[NameIdFormat.Unspecified]: 'application_details.saml_idp_name_id_format.unspecified',
|
||||
}) satisfies NameIdFormatToTranslationKey;
|
||||
|
||||
const nameIdFormatToOptionDescriptionMap = Object.freeze({
|
||||
[NameIdFormat.EmailAddress]:
|
||||
'application_details.saml_idp_name_id_format.email_address_description',
|
||||
[NameIdFormat.Transient]: 'application_details.saml_idp_name_id_format.transient_description',
|
||||
[NameIdFormat.Persistent]: 'application_details.saml_idp_name_id_format.persistent_description',
|
||||
[NameIdFormat.Unspecified]: 'application_details.saml_idp_name_id_format.unspecified_description',
|
||||
}) satisfies NameIdFormatToTranslationKey;
|
||||
|
||||
function Settings({ data, mutateApplication, isDeleted }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tenantEndpoint } = useContext(AppDataContext);
|
||||
|
@ -60,6 +92,8 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
|
|||
);
|
||||
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
|
@ -280,6 +314,75 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
|
|||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="application_details.saml_idp_name_id_format.title">
|
||||
<Controller
|
||||
name="nameIdFormat"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
options={Object.values(NameIdFormat).map((format) => ({
|
||||
value: format,
|
||||
title: (
|
||||
<span>
|
||||
{t(nameIdFormatToOptionMap[format])}
|
||||
<span className={styles.nameIdFormatDescription}>
|
||||
({t(nameIdFormatToOptionDescriptionMap[format])})
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="application_details.saml_encryption_config.encrypt_assertion">
|
||||
<Switch
|
||||
label={t('application_details.saml_encryption_config.encrypt_assertion_description')}
|
||||
{...register('encryptSamlAssertion')}
|
||||
/>
|
||||
</FormField>
|
||||
{watch('encryptSamlAssertion') && (
|
||||
<>
|
||||
<FormField title="application_details.saml_encryption_config.encrypt_then_sign">
|
||||
<Switch
|
||||
label={t(
|
||||
'application_details.saml_encryption_config.encrypt_then_sign_description'
|
||||
)}
|
||||
{...register('encryptThenSignSamlAssertion')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="application_details.saml_encryption_config.certificate"
|
||||
tip={t('application_details.saml_encryption_config.certificate_tooltip')}
|
||||
>
|
||||
<Textarea
|
||||
rows={5}
|
||||
error={errors.certificate?.message}
|
||||
{...register('certificate', {
|
||||
validate: (value) => {
|
||||
if (!value) {
|
||||
return t(
|
||||
'application_details.saml_encryption_config.certificate_missing_error'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
validateCertificate(value) ||
|
||||
t(
|
||||
'application_details.saml_encryption_config.certificate_invalid_format_error'
|
||||
)
|
||||
);
|
||||
},
|
||||
})}
|
||||
placeholder={t(
|
||||
'application_details.saml_encryption_config.certificate_placeholder'
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} onConfirm={reset} />
|
||||
|
@ -288,3 +391,4 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
|
|||
}
|
||||
|
||||
export default Settings;
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -40,3 +40,8 @@
|
|||
button.add {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.nameIdFormatDescription {
|
||||
margin-inline-start: _.unit(2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
|
@ -3,14 +3,14 @@ import {
|
|||
type PatchSamlApplication,
|
||||
type SamlApplicationResponse,
|
||||
} from '@logto/schemas';
|
||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||
import { cond, removeUndefinedKeys } from '@silverhand/essentials';
|
||||
|
||||
import { type SamlApplicationFormData } from './Settings';
|
||||
|
||||
export const parseSamlApplicationResponseToFormData = (
|
||||
data: SamlApplicationResponse
|
||||
): SamlApplicationFormData => {
|
||||
const { id, description, name, entityId, acsUrl } = data;
|
||||
const { id, description, name, entityId, acsUrl, encryption, nameIdFormat } = data;
|
||||
|
||||
return {
|
||||
id,
|
||||
|
@ -18,6 +18,10 @@ export const parseSamlApplicationResponseToFormData = (
|
|||
name,
|
||||
entityId,
|
||||
acsUrl: acsUrl?.url ?? null,
|
||||
nameIdFormat,
|
||||
encryptSamlAssertion: encryption?.encryptAssertion ?? false,
|
||||
encryptThenSignSamlAssertion: encryption?.encryptThenSign ?? false,
|
||||
certificate: encryption?.certificate,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -27,7 +31,17 @@ export const parseFormDataToSamlApplicationRequest = (
|
|||
id: string;
|
||||
payload: PatchSamlApplication;
|
||||
} => {
|
||||
const { id, description, name, entityId, acsUrl } = data;
|
||||
const {
|
||||
id,
|
||||
description,
|
||||
name,
|
||||
entityId,
|
||||
acsUrl,
|
||||
encryptSamlAssertion,
|
||||
encryptThenSignSamlAssertion,
|
||||
certificate,
|
||||
nameIdFormat,
|
||||
} = data;
|
||||
|
||||
// If acsUrl value is empty string, it should be removed. Convert it to null.
|
||||
const acsUrlData = acsUrl ? { url: acsUrl, binding: BindingType.Post } : null;
|
||||
|
@ -39,6 +53,17 @@ export const parseFormDataToSamlApplicationRequest = (
|
|||
name,
|
||||
entityId,
|
||||
acsUrl: acsUrlData,
|
||||
nameIdFormat,
|
||||
...cond(
|
||||
encryptSamlAssertion &&
|
||||
certificate && {
|
||||
certificate: {
|
||||
encryptAssertion: encryptSamlAssertion,
|
||||
certificate,
|
||||
encryptThenSign: encryptThenSignSamlAssertion,
|
||||
},
|
||||
}
|
||||
),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -62,3 +87,34 @@ export const camelCaseToSentenceCase = (input: string): string => {
|
|||
const capitalizedFirstWord = words[0].charAt(0).toUpperCase() + words[0].slice(1);
|
||||
return [capitalizedFirstWord, ...words.slice(1)].join(' ');
|
||||
};
|
||||
|
||||
export const validateCertificate = (certificate: string) => {
|
||||
// Remove any whitespace and newline characters for consistent validation
|
||||
const normalizedCert = certificate.replaceAll(/\s/g, '');
|
||||
|
||||
// Check if the certificate starts with the header and ends with the footer
|
||||
if (
|
||||
!normalizedCert.startsWith('-----BEGINCERTIFICATE-----') ||
|
||||
!normalizedCert.endsWith('-----ENDCERTIFICATE-----')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the base64 content between the header and footer
|
||||
const base64Content = normalizedCert
|
||||
.replace('-----BEGINCERTIFICATE-----', '')
|
||||
.replace('-----ENDCERTIFICATE-----', '');
|
||||
|
||||
// Check if the content is valid base64
|
||||
try {
|
||||
if (base64Content.length % 4 !== 0) {
|
||||
return false;
|
||||
}
|
||||
if (!/^[\d+/A-Za-z]*={0,2}$/.test(base64Content)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { NameIdFormat } from '@logto/schemas';
|
||||
import nock from 'nock';
|
||||
|
||||
import { SamlApplication } from './index.js';
|
||||
|
@ -25,6 +26,7 @@ describe('SamlApplication', () => {
|
|||
privateKey: 'mock-private-key',
|
||||
certificate: 'mock-certificate',
|
||||
secret: 'mock-secret',
|
||||
nameIdFormat: NameIdFormat.Persistent,
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
|
|
|
@ -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';
|
||||
|
@ -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.
|
||||
|
@ -68,6 +90,8 @@ const validateSamlApplicationDetails = (
|
|||
privateKey,
|
||||
certificate,
|
||||
secret,
|
||||
nameIdFormat,
|
||||
encryption,
|
||||
} = details;
|
||||
|
||||
assertThat(acsUrl, 'application.saml.acs_url_required');
|
||||
|
@ -84,6 +108,8 @@ const validateSamlApplicationDetails = (
|
|||
redirectUri: redirectUris[0],
|
||||
privateKey,
|
||||
certificate,
|
||||
nameIdFormat,
|
||||
encryption,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()}`;
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { NameIdFormat } from '@logto/schemas';
|
||||
|
||||
import { generateAutoSubmitForm, buildSamlAssertionNameId } from './utils.js';
|
||||
|
||||
describe('buildSamlAssertionNameId', () => {
|
||||
|
@ -8,7 +10,7 @@ describe('buildSamlAssertionNameId', () => {
|
|||
email_verified: true,
|
||||
};
|
||||
|
||||
const result = buildSamlAssertionNameId(user);
|
||||
const result = buildSamlAssertionNameId(user, [NameIdFormat.EmailAddress]);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
|
@ -23,7 +25,7 @@ describe('buildSamlAssertionNameId', () => {
|
|||
email_verified: false,
|
||||
};
|
||||
|
||||
const result = buildSamlAssertionNameId(user);
|
||||
const result = buildSamlAssertionNameId(user, [NameIdFormat.Persistent]);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
|
@ -36,7 +38,7 @@ describe('buildSamlAssertionNameId', () => {
|
|||
sub: 'user123',
|
||||
};
|
||||
|
||||
const result = buildSamlAssertionNameId(user);
|
||||
const result = buildSamlAssertionNameId(user, [NameIdFormat.Persistent]);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
|
@ -52,7 +54,7 @@ describe('buildSamlAssertionNameId', () => {
|
|||
};
|
||||
const format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
|
||||
|
||||
const result = buildSamlAssertionNameId(user, format);
|
||||
const result = buildSamlAssertionNameId(user, [format]);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: format,
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
// TODO: refactor this file to reduce LOC
|
||||
import saml from 'samlify';
|
||||
import { NameIdFormat } from '@logto/schemas';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
|
||||
|
@ -13,43 +15,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 => {
|
||||
|
|
|
@ -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
|
||||
|
@ -51,6 +54,8 @@ const samlApplicationDetailsGuard = Applications.guard
|
|||
attributeMapping: true,
|
||||
entityId: true,
|
||||
acsUrl: true,
|
||||
nameIdFormat: true,
|
||||
encryption: true,
|
||||
})
|
||||
)
|
||||
.merge(
|
||||
|
@ -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}
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -219,6 +219,33 @@ const application_details = {
|
|||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
saml_idp_name_id_format: {
|
||||
title: 'Name ID format',
|
||||
description: 'Select the name ID format of the SAML IdP.',
|
||||
persistent: 'Persistent',
|
||||
persistent_description: 'Use Logto user ID as Name ID',
|
||||
transient: 'Transient',
|
||||
transient_description: 'Use one-time user ID as Name ID',
|
||||
unspecified: 'Unspecified',
|
||||
unspecified_description: 'Use Logto user ID as Name ID',
|
||||
email_address: 'Email address',
|
||||
email_address_description: 'Use email address as Name ID',
|
||||
},
|
||||
saml_encryption_config: {
|
||||
encrypt_assertion: 'Encrypt SAML assertion',
|
||||
encrypt_assertion_description: 'By enabling this option, the SAML assertion will be encrypted.',
|
||||
encrypt_then_sign: 'Encrypt then sign',
|
||||
encrypt_then_sign_description:
|
||||
'By enabling this option, the SAML assertion will be encrypted and then signed; otherwise, the SAML assertion will be signed and then encrypted.',
|
||||
certificate: 'Certificate',
|
||||
certificate_tooltip:
|
||||
'Copy and paste the x509 certificate you get from your service provider to encrypt the SAML assertion.',
|
||||
certificate_placeholder:
|
||||
'-----BEGIN CERTIFICATE-----\nMIICYDCCAcmgAwIBA...\n-----END CERTIFICATE-----\n',
|
||||
certificate_missing_error: 'Certificate is required.',
|
||||
certificate_invalid_format_error:
|
||||
'Invalid certificate format detected. Please check the certificate format and try again.',
|
||||
},
|
||||
saml_app_attribute_mapping: {
|
||||
name: 'Attribute mappings',
|
||||
title: 'Base attribute mappings',
|
||||
|
|
Loading…
Add table
Reference in a new issue