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.',