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:
parent
f02d8cb5ba
commit
00734031f8
6 changed files with 127 additions and 31 deletions
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
Loading…
Reference in a new issue