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:
parent
4bcc9c1cf4
commit
edeb7cc8da
5 changed files with 127 additions and 52 deletions
|
@ -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({
|
||||
|
|
41
packages/core/src/routes/saml-application/utils.ts
Normal file
41
packages/core/src/routes/saml-application/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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_', '');
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Reference in a new issue