0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): implement SAML IdP response flow

This commit is contained in:
simeng-li 2024-12-04 14:44:30 +08:00 committed by Darcy Ye
parent f02d8cb5ba
commit 00734031f8
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
6 changed files with 127 additions and 31 deletions

View file

@ -1,25 +1,30 @@
export const samlLogInResponseTemplate = `
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}">
<saml:Issuer>{Issuer}</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="{StatusCode}"/>
</samlp:Status>
<saml:Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Issuer>{Issuer}</saml:Issuer>
<saml:Subject>
<saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}">
<saml:AudienceRestriction>
<saml:Audience>{Audience}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}">
<Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">{Issuer}</Issuer>
<Status>
<StatusCode Value="{StatusCode}"/>
</Status>
<Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
<Issuer>{Issuer}</Issuer>
<Subject>
<NameID Format="{NameIDFormat}">{NameID}</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/>
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}">
<AudienceRestriction>
<Audience>{Audience}</Audience>
</AudienceRestriction>
</Conditions>
<AuthnStatement AuthnInstant="{IssueInstant}">
<AuthnContext>
<AuthnContextClassRef>{AuthnContextClassRef}</AuthnContextClassRef>
</AuthnContext>
</AuthnStatement>
{AttributeStatement}
</saml:Assertion>
</samlp:Response>`;
</Assertion>
</Response>`;
export const samlAttributeNameFormatBasic = 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic';

View file

@ -12,6 +12,7 @@ import {
handleOidcCallbackAndGetUserInfo,
setupSamlProviders,
buildSamlAppCallbackUrl,
samlAppCustomDataGuard,
} from './utils.js';
const samlApplicationSignInCallbackQueryParametersGuard = z.union([
@ -78,6 +79,7 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
const {
secret,
oidcClientMetadata: { redirectUris },
customData,
} = await applications.findApplicationById(id);
const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
@ -86,6 +88,18 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
'oidc.invalid_redirect_uri'
);
// For demo purpose
const result = samlAppCustomDataGuard.safeParse(customData);
if (!result.success) {
throw new RequestError(
{
code: 'application.invalid_custom_data',
},
{ details: result.error.flatten() }
);
}
// TODO: should be able to handle `state` and code verifier etc.
const { code } = query;
@ -110,8 +124,21 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
assertThat(acsUrl, 'application.saml.acs_url_required');
// Setup SAML providers and create response
const { idp, sp } = setupSamlProviders(metadata, privateKey, entityId, acsUrl);
const { context, entityEndpoint } = await createSamlResponse(idp, sp, userInfo);
const { idp, sp } = setupSamlProviders(
metadata,
privateKey,
entityId,
acsUrl,
result.data.encryptAssertion,
result.data.encryptionAlgorithm,
result.data.spCertificate
);
const { context, entityEndpoint } = await createSamlResponse(
idp,
sp,
userInfo,
result.data.encryptThenSign
);
// Return auto-submit form
ctx.body = generateAutoSubmitForm(entityEndpoint, context);

View file

@ -23,6 +23,8 @@ import {
validateAcsUrl,
} from '../libraries/utils.js';
import { samlAppCustomDataGuard } from './utils.js';
export default function samlApplicationRoutes<T extends ManagementApiRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
@ -54,6 +56,15 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
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<T extends ManagementApiRouter>(
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;

View file

@ -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: `<AttributeStatement>{Attributes}</AttributeStatement>`,
},
attributeTemplate: {
context: `<Attribute Name="{Name}" NameFormat="{NameFormat}"><AttributeValue>{Value}</AttributeValue></Attribute>`,
},
},
},
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(),
});

View file

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

View file

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