0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-24 22:05:56 -05:00

fix(core): add AuthnStatement for SAML assertion response (#7057)

This commit is contained in:
Darcy Ye 2025-02-21 14:20:13 +08:00 committed by GitHub
parent 4bcc9c1cf4
commit edeb7cc8da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 127 additions and 52 deletions

View file

@ -2,7 +2,7 @@
// TODO: refactor this file to reduce LOC
import { authRequestInfoGuard } from '@logto/schemas';
import { generateStandardId, generateStandardShortId } from '@logto/shared';
import { cond, type Nullable, removeUndefinedKeys, trySafe } from '@silverhand/essentials';
import { cond, removeUndefinedKeys, trySafe } from '@silverhand/essentials';
import { addMinutes } from 'date-fns';
import { z } from 'zod';
@ -16,6 +16,8 @@ import { generateAutoSubmitForm } from '#src/saml-application/SamlApplication/ut
import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { verifyAndGetSamlSessionData } from './utils.js';
const samlApplicationSignInCallbackQueryParametersGuard = z
.object({
code: z.string(),
@ -147,37 +149,31 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
assertThat(redirectUri === samlApplication.samlAppCallbackUrl, 'oidc.invalid_redirect_uri');
}
// eslint-disable-next-line @silverhand/fp/no-let
let relayState: Nullable<string> = null;
// eslint-disable-next-line @silverhand/fp/no-let
let samlRequestId: Nullable<string> = null;
if (state) {
const sessionId = ctx.cookies.get(spInitiatedSamlSsoSessionCookieName);
assertThat(
const { relayState, samlRequestId, sessionId, sessionExpiresAt } =
await verifyAndGetSamlSessionData(ctx, queries.samlApplicationSessions, state);
log.append({
session: {
relayState,
samlRequestId,
sessionId,
'application.saml.sp_initiated_saml_sso_session_not_found_in_cookies'
);
const session = await findSessionById(sessionId);
assertThat(session, 'application.saml.sp_initiated_saml_sso_session_not_found');
// eslint-disable-next-line @silverhand/fp/no-mutation
relayState = session.relayState;
// eslint-disable-next-line @silverhand/fp/no-mutation
samlRequestId = session.samlRequestId;
assertThat(session.oidcState === state, 'application.saml.state_mismatch');
}
sessionExpiresAt,
},
});
// Handle OIDC callback and get user info
const userInfo = await samlApplication.handleOidcCallbackAndGetUserInfo({
code,
});
log.append({
userInfo,
});
const { context, entityEndpoint } = await samlApplication.createSamlResponse({
userInfo,
relayState,
samlRequestId,
sessionId,
sessionExpiresAt,
});
log.append({

View file

@ -0,0 +1,41 @@
import { type Nullable } from '@silverhand/essentials';
import { type Context } from 'koa';
import { spInitiatedSamlSsoSessionCookieName } from '#src/constants/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
export const verifyAndGetSamlSessionData = async (
ctx: Context,
queries: Queries['samlApplicationSessions'],
state?: string
): Promise<{
relayState: Nullable<string>;
samlRequestId: Nullable<string>;
sessionId?: string;
sessionExpiresAt?: string;
}> => {
if (!state) {
return {
relayState: null,
samlRequestId: null,
};
}
const sessionId = ctx.cookies.get(spInitiatedSamlSsoSessionCookieName);
assertThat(sessionId, 'application.saml.sp_initiated_saml_sso_session_not_found_in_cookies');
const session = await queries.findSessionById(sessionId);
assertThat(session, 'application.saml.sp_initiated_saml_sso_session_not_found');
const { relayState, samlRequestId } = session;
const sessionExpiresAt = new Date(session.expiresAt).toISOString();
assertThat(session.oidcState === state, 'application.saml.state_mismatch');
return {
relayState,
samlRequestId,
sessionId,
sessionExpiresAt,
};
};

View file

@ -1,3 +1,5 @@
import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
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>
@ -17,6 +19,11 @@ export const samlLogInResponseTemplate = `
<saml:Audience>{Audience}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{IssueInstant}" SessionNotOnOrAfter="{SessionNotOnOrAfter}" SessionIndex="{SessionIndex}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>{AuthnContextClassRef}</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{AttributeStatement}
</saml:Assertion>
</samlp:Response>`;
@ -34,3 +41,9 @@ export const samlValueXmlnsXsi = {
boolean: samlValueXmlnsXsiBoolean,
datetime: samlValueXmlnsXsiDatetime,
};
export const fallbackAttributes: Array<keyof IdTokenProfileStandardClaims> = [
'sub',
'email',
'name',
];

View file

@ -79,6 +79,8 @@ describe('SamlApplication', () => {
const result = samlApp.exposedCreateSamlTemplateCallback({
userInfo: mockUser,
samlRequestId: null,
sessionId: undefined,
sessionExpiresAt: undefined,
})('ID:NameID:attrEmail:attrName');
const generatedId = result.id.replace('ID_', '');

View file

@ -10,7 +10,7 @@ import {
type SamlAttributeMapping,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { cond, tryThat, type Nullable } from '@silverhand/essentials';
import { cond, tryThat, type Nullable, type Optional } from '@silverhand/essentials';
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
import { XMLValidator } from 'fast-xml-parser';
import saml from 'samlify';
@ -39,6 +39,7 @@ import {
samlLogInResponseTemplate,
samlAttributeNameFormatBasic,
samlValueXmlnsXsi,
fallbackAttributes,
} from './consts.js';
import {
buildSamlAssertionNameId,
@ -184,10 +185,14 @@ export class SamlApplication {
userInfo,
relayState,
samlRequestId,
sessionId,
sessionExpiresAt,
}: {
userInfo: IdTokenProfileStandardClaims;
relayState: Nullable<string>;
samlRequestId: Nullable<string>;
sessionId: Optional<string>;
sessionExpiresAt: Optional<string>;
}): Promise<{ context: string; entityEndpoint: string }> => {
// TODO: fix binding method
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -197,7 +202,7 @@ export class SamlApplication {
null,
'post',
userInfo,
this.createSamlTemplateCallback({ userInfo, samlRequestId }),
this.createSamlTemplateCallback({ userInfo, samlRequestId, sessionId, sessionExpiresAt }),
this.config.encryption?.encryptThenSign,
relayState ?? undefined
);
@ -400,9 +405,13 @@ export class SamlApplication {
({
userInfo,
samlRequestId,
sessionId,
sessionExpiresAt,
}: {
userInfo: IdTokenProfileStandardClaims;
samlRequestId: Nullable<string>;
sessionId: Optional<string>;
sessionExpiresAt: Optional<string>;
}) =>
(template: string) => {
const assertionConsumerServiceUrl = this.sp.entityMeta.getAssertionConsumerService(
@ -434,20 +443,12 @@ export class SamlApplication {
NameIDFormat,
NameID,
InResponseTo: samlRequestId ?? 'null',
/**
* User attributes for SAML response
*
* @todo Support custom attribute mapping
* @see {@link https://github.com/tngan/samlify/blob/master/src/libsaml.ts#L275-L300|samlify implementation}
*
* @remarks
* By examining the code provided in the link above, we can define all the attributes supported by the attribute mapping here. Only the attributes defined in the `loginResponseTemplate.attributes` added when creating the IdP instance will appear in the SAML response.
*/
// Keep the `attrSub`, `attrEmail` and `attrName` attributes since attribute mapping can be empty.
attrSub: userInfo.sub,
attrEmail: userInfo.email,
attrName: userInfo.name,
...this.buildSamlAttributesTagValues(userInfo),
// Since we currently does not distinguish the way user used to pass authentication, we set the default AuthnContextClassRef to passwordProtectedTransport.
AuthnContextClassRef:
saml.Constants.namespace.authnContextClassRef.passwordProtectedTransport,
SessionNotOnOrAfter: sessionExpiresAt ?? '',
SessionIndex: sessionId ?? '',
};
const context = saml.SamlLib.replaceTagsByValue(template, tagValues);
@ -461,7 +462,10 @@ export class SamlApplication {
protected readonly buildLoginResponseTemplate = () => {
return {
context: samlLogInResponseTemplate,
attributes: Object.values(this.config.attributeMapping).map((value) => ({
attributes: (Object.entries(this.config.attributeMapping).length > 0
? Object.values(this.config.attributeMapping)
: fallbackAttributes
).map((value) => ({
name: value,
valueTag: value,
nameFormat: samlAttributeNameFormatBasic,
@ -472,21 +476,40 @@ export class SamlApplication {
protected readonly buildSamlAttributesTagValues = (
userInfo: IdTokenProfileStandardClaims
): Record<string, string> => {
return Object.fromEntries(
Object.entries(this.config.attributeMapping)
.map(([key, value]) => {
// eslint-disable-next-line no-restricted-syntax
return [value, userInfo[key as keyof IdTokenProfileStandardClaims] ?? null] as [
string,
unknown,
];
})
.map(([key, value]) => [
generateSamlAttributeTag(key),
typeof value === 'object' ? JSON.stringify(value) : String(value),
])
);
): Record<string, Nullable<string>> => {
return Object.entries(this.config.attributeMapping).length > 0
? Object.fromEntries(
Object.entries(this.config.attributeMapping)
.map(([key, value]) => {
// eslint-disable-next-line no-restricted-syntax
return [value, userInfo[key as keyof IdTokenProfileStandardClaims] ?? null] as [
string,
unknown,
];
})
.map(([key, value]) => [
generateSamlAttributeTag(key),
typeof value === 'object' ? JSON.stringify(value) : String(value),
])
)
: /**
* User attributes for SAML response
*
* @todo Support custom attribute mapping
* @see {@link https://github.com/tngan/samlify/blob/master/src/libsaml.ts#L275-L300|samlify implementation}
*
* @remarks
* By examining the code provided in the link above, we can define all the attributes supported by the attribute mapping here. Only the attributes defined in the `loginResponseTemplate.attributes` added when creating the IdP instance will appear in the SAML response.
*/
// Keep the `attrSub`, `attrEmail` and `attrName` attributes since attribute mapping can be empty.
Object.fromEntries(
fallbackAttributes.map((attribute) => [
generateSamlAttributeTag(attribute),
(typeof userInfo[attribute] === 'boolean'
? String(userInfo[attribute])
: userInfo[attribute]) ?? null,
])
);
};
// Used to check whether xml content is valid in format.