diff --git a/packages/core/src/saml-applications/libraries/consts.ts b/packages/core/src/saml-applications/libraries/consts.ts index 4eb72609d..8a5d66264 100644 --- a/packages/core/src/saml-applications/libraries/consts.ts +++ b/packages/core/src/saml-applications/libraries/consts.ts @@ -1,25 +1,30 @@ export const samlLogInResponseTemplate = ` - - {Issuer} - - - - - {Issuer} - - {NameID} - - - - - - - {Audience} - - + + {Issuer} + + + + + {Issuer} + + {NameID} + + + + + + + {Audience} + + + + + {AuthnContextClassRef} + + {AttributeStatement} - -`; + +`; export const samlAttributeNameFormatBasic = 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'; diff --git a/packages/core/src/saml-applications/routes/anonymous.ts b/packages/core/src/saml-applications/routes/anonymous.ts index 15ae85ac1..c36542126 100644 --- a/packages/core/src/saml-applications/routes/anonymous.ts +++ b/packages/core/src/saml-applications/routes/anonymous.ts @@ -12,6 +12,7 @@ import { handleOidcCallbackAndGetUserInfo, setupSamlProviders, buildSamlAppCallbackUrl, + samlAppCustomDataGuard, } from './utils.js'; const samlApplicationSignInCallbackQueryParametersGuard = z.union([ @@ -78,6 +79,7 @@ export default function samlApplicationAnonymousRoutes( ...[router, { queries, libraries }]: RouterInitArgs ) { @@ -54,6 +56,15 @@ export default function samlApplicationRoutes( async (ctx, next) => { const { name, description, customData, ...config } = ctx.guard.body; + const result = samlAppCustomDataGuard.safeParse(customData); + + if (!result.success) { + throw new RequestError({ + code: 'application.invalid_custom_data', + details: result.error.flatten(), + }); + } + if (config.acsUrl) { validateAcsUrl(config.acsUrl); } @@ -123,6 +134,17 @@ export default function samlApplicationRoutes( async (ctx, next) => { const { id } = ctx.guard.params; + if (ctx.guard.body.customData) { + const result = samlAppCustomDataGuard.safeParse(ctx.guard.body.customData); + + if (!result.success) { + throw new RequestError({ + code: 'application.invalid_custom_data', + details: result.error.flatten(), + }); + } + } + const updatedSamlApplication = await updateSamlApplicationById(id, ctx.guard.body); ctx.status = 200; diff --git a/packages/core/src/saml-applications/routes/utils.ts b/packages/core/src/saml-applications/routes/utils.ts index 076d127c7..279312198 100644 --- a/packages/core/src/saml-applications/routes/utils.ts +++ b/packages/core/src/saml-applications/routes/utils.ts @@ -1,6 +1,6 @@ import { parseJson } from '@logto/connector-kit'; import { generateStandardId } from '@logto/shared'; -import { tryThat, appendPath } from '@silverhand/essentials'; +import { cond, tryThat, appendPath } from '@silverhand/essentials'; import camelcaseKeys from 'camelcase-keys'; import saml from 'samlify'; import { ZodError, z } from 'zod'; @@ -106,6 +106,8 @@ export const createSamlTemplateCallback = NameID, // TODO: should get the request ID from the input parameters, pending https://github.com/logto-io/logto/pull/6881. InResponseTo: 'null', + // Hardcode AuthnContextClassRef to 'urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified' for ADFS for now. + AuthnContextClassRef: 'urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified', /** * User attributes for SAML response * @@ -162,7 +164,8 @@ export const exchangeAuthorizationCode = async ( export const createSamlResponse = async ( idp: saml.IdentityProviderInstance, sp: saml.ServiceProviderInstance, - userInfo: IdTokenProfileStandardClaims + userInfo: IdTokenProfileStandardClaims, + encryptThenSign?: boolean ): Promise<{ context: string; entityEndpoint: string }> => { // TODO: fix binding method // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -172,7 +175,8 @@ export const createSamlResponse = async ( null, 'post', userInfo, - createSamlTemplateCallback(idp, sp, userInfo) + createSamlTemplateCallback(idp, sp, userInfo), + encryptThenSign ); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -256,13 +260,17 @@ export const setupSamlProviders = ( metadata: string, privateKey: string, entityId: string, - acsUrl: { binding: string; url: string } + acsUrl: { binding: string; url: string }, + encryptAssertion?: boolean, + encryptionAlgorithm?: string, + spCertificate?: string ) => { // eslint-disable-next-line new-cap const idp = saml.IdentityProvider({ metadata, privateKey, - isAssertionEncrypted: false, + isAssertionEncrypted: encryptAssertion ?? false, + ...cond(encryptionAlgorithm && { encryptionAlgorithm }), loginResponseTemplate: { context: samlLogInResponseTemplate, attributes: [ @@ -270,20 +278,30 @@ export const setupSamlProviders = ( name: 'email', valueTag: 'email', nameFormat: samlAttributeNameFormatBasic, + // Have to define this to make Typescript happy although we do not use it in the template. valueXsiType: samlValueXmlnsXsi.string, }, { name: 'name', valueTag: 'name', nameFormat: samlAttributeNameFormatBasic, + // Have to define this to make Typescript happy although we do not use it in the template. valueXsiType: samlValueXmlnsXsi.string, }, ], + additionalTemplates: { + attributeStatementTemplate: { + context: `{Attributes}`, + }, + attributeTemplate: { + context: `{Value}`, + }, + }, }, - nameIDFormat: [ - saml.Constants.namespace.format.emailAddress, - saml.Constants.namespace.format.persistent, - ], + tagPrefix: { + encryptAssertion: '', + }, + nameIDFormat: [saml.Constants.namespace.format.emailAddress], }); // eslint-disable-next-line new-cap @@ -295,6 +313,7 @@ export const setupSamlProviders = ( Location: acsUrl.url, }, ], + ...cond(spCertificate && { encryptCert: spCertificate }), }); return { idp, sp }; @@ -302,3 +321,24 @@ export const setupSamlProviders = ( export const buildSamlAppCallbackUrl = (baseUrl: URL, samlApplicationId: string) => appendPath(baseUrl, `api/saml-applications/${samlApplicationId}/callback`).toString(); + +export const samlAppCustomDataGuard = z.object({ + spCertificate: z.string().optional(), + encryptThenSign: z.boolean().optional(), + encryptAssertion: z.boolean().optional(), + encryptionAlgorithm: z + .enum([ + 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', // AES_128 + 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', // AES_256 + 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc', // TRI_DEC + 'http://www.w3.org/2009/xmlenc11#aes128-gcm', // AES_128_GCM + ]) + .optional(), + // Canonicalization and transformation Algorithms + transformationAlgorithm: z + .enum([ + 'http://www.w3.org/2000/09/xmldsig#enveloped-signature', + 'http://www.w3.org/2001/10/xml-exc-c14n#', + ]) + .optional(), +}); diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index 9fa9e4d49..d03431f72 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -20,6 +20,7 @@ const application = { should_delete_custom_domains_first: 'Should delete custom domains first.', no_legacy_secret_found: 'The application does not have a legacy secret.', secret_name_exists: 'Secret name already exists.', + invalid_custom_data: 'Invalid custom data.', saml: { use_saml_app_api: 'Use `[METHOD] /saml-applications(/.*)?` API to operate SAML app.', saml_application_only: 'The API is only available for SAML applications.', diff --git a/packages/phrases/src/locales/en/errors/oidc.ts b/packages/phrases/src/locales/en/errors/oidc.ts index 4f65ef651..8984a1864 100644 --- a/packages/phrases/src/locales/en/errors/oidc.ts +++ b/packages/phrases/src/locales/en/errors/oidc.ts @@ -8,6 +8,7 @@ const oidc = { invalid_grant: 'Grant request is invalid.', invalid_redirect_uri: "`redirect_uri` did not match any of the client's registered `redirect_uris`.", + redirect_uri_not_set: '`redirect_uri` is not set.', access_denied: 'Access denied.', invalid_target: 'Invalid resource indicator.', unsupported_grant_type: 'Unsupported `grant_type` requested.',