0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

fix(core,test): fix saml sp config and refactor saml class internal util methods (#4878)

This commit is contained in:
Darcy Ye 2023-11-17 23:51:52 +08:00 committed by GitHub
parent 5b0ea2192b
commit 5832b30276
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 219 additions and 141 deletions

View file

@ -1,24 +1,27 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { assert, appendPath, conditional, type Optional } from '@silverhand/essentials';
import { type Optional } from '@silverhand/essentials';
import * as saml from 'samlify';
import { z } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import { ssoPath } from '#src/routes/interaction/const.js';
import {
type SamlConfig,
type SamlConnectorConfig,
samlMetadataGuard,
type ExtendedSocialUserInfo,
type SamlServiceProviderMetadata,
type SamlMetadata,
type SamlIdentityProviderMetadata,
samlIdentityProviderMetadataGuard,
} from '../types/saml.js';
import {
parseXmlMetadata,
getRawSamlMetadata,
getSamlMetadataXml,
handleSamlAssertion,
attributeMappingPostProcessor,
getExtendedUserInfoFromRawUserProfile,
buildSpEntityId,
buildAssertionConsumerServiceUrl,
} from './utils.js';
/**
@ -29,66 +32,52 @@ import {
* All the SAML single sign-on connector should extend this class.
*
* @property config The SAML connector config
* @property acsUrl The SAML connector's assertion consumer service URL {@link file://src/routes/authn.ts}
* @property _rawSamlMetadata The cached raw SAML metadata (in XML-format) from the raw SAML SSO connector config
* @property _parsedSamlMetadata The cached parsed SAML metadata from the raw SAML SSO connector config
* @property _samlAssertionContent The cached parsed SAML assertion from IdP (with attribute mapping applied)
* @property assertionConsumerServiceUrl The SAML connector's assertion consumer service URL {@link file://src/routes/authn.ts}
* @property _samlIdpMetadataXml The cached raw SAML metadata (in XML-format) from the raw SAML SSO connector config
*
* @method getSamlConfig Parse and return SAML config from the XML-format metadata. Throws error if config is invalid.
* @method getSamlIdpMetadata Parse and return SAML config from the SAML connector config. Throws error if config is invalid.
* @method parseSamlAssertion Parse and store the SAML assertion from IdP.
* @method getSingleSignOnUrl Get the SAML SSO URL.
* @method getIdpXmlMetadata Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config.
* @method getIdpMetadataXml Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config.
* @method getIdpMetadataJson Get manually configured IdP SAML metadata from the raw SAML SSO connector config.
*/
class SamlConnector {
readonly acsUrl: string;
private _rawSamlMetadata: Optional<string>;
private _parsedSamlMetadata: Optional<SamlConfig>;
private readonly assertionConsumerServiceUrl: string;
private readonly spEntityId: string;
private readonly serviceProviderMetadata: SamlServiceProviderMetadata;
private _samlIdpMetadataXml: Optional<string>;
private _samlIdpMetadata: Optional<SamlIdentityProviderMetadata>;
private _identityProvider: Optional<saml.IdentityProviderInstance>;
constructor(
private readonly config: SamlConnectorConfig,
tenantId: string,
ssoConnectorId: string
) {
this.acsUrl = appendPath(
this.assertionConsumerServiceUrl = buildAssertionConsumerServiceUrl(
getTenantEndpoint(tenantId, EnvSet.values),
`api/authn/${ssoPath}/saml/${ssoConnectorId}`
).toString();
ssoConnectorId
);
this.spEntityId = buildSpEntityId(EnvSet.values, tenantId, ssoConnectorId);
this.serviceProviderMetadata = {
entityId: this.spEntityId,
assertionConsumerServiceUrl: this.assertionConsumerServiceUrl,
};
}
/**
* Get SAML config along with parsed metadata from raw SAML SSO connector config.
* Return parsed SAML metadata.
*
* @returns Parsed SAML config along with it's parsed metadata.
* @returns Parsed SAML metadata (contains both SP and IdP metadata).
*/
async getSamlConfig(): Promise<SamlConfig> {
if (this._parsedSamlMetadata) {
return this._parsedSamlMetadata;
}
// Get raw SAML metadata ready.
await this.getIdpXmlMetadata();
const samlConfig = conditional(
this._rawSamlMetadata && parseXmlMetadata(this._rawSamlMetadata)
);
if (samlConfig) {
this._parsedSamlMetadata = { ...samlConfig, ...this.config };
return this._parsedSamlMetadata;
}
// Required fields of metadata should not be undefined.
const result = samlMetadataGuard
.pick({ signInEndpoint: true, x509Certificate: true, entityId: true })
.safeParse(this.config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
// Simply return `this.config` should be of SamlConfig type, but seems the type inference is not that smart.
this._parsedSamlMetadata = { ...this.config, ...result.data };
return this._parsedSamlMetadata;
async getSamlConfig(): Promise<SamlMetadata> {
const serviceProvider = this.serviceProviderMetadata;
const identityProvider = await this.getSamlIdpMetadata();
return { serviceProvider, identityProvider };
}
/**
@ -99,21 +88,13 @@ class SamlConnector {
* @returns The parsed SAML assertion from IdP (with attribute mapping applied).
*/
async parseSamlAssertion(assertion: Record<string, unknown>): Promise<ExtendedSocialUserInfo> {
const parsedConfig = await this.getSamlConfig();
const profileMap = attributeMappingPostProcessor(parsedConfig.attributeMapping);
const idpMetadataXml = await this.getIdpXmlMetadata();
const { x509Certificate } = await this.getSamlIdpMetadata();
const profileMap = attributeMappingPostProcessor(this.config.attributeMapping);
// Add SSO connector errors and replace connector errors.
assert(
idpMetadataXml,
new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
message: "Can not get identity provider's metadata, please check configuration.",
})
);
const samlAssertionContent = await handleSamlAssertion(assertion, {
...parsedConfig,
idpMetadataXml,
const identityProvider = await this.getIdentityProvider();
const samlAssertionContent = await handleSamlAssertion(assertion, identityProvider, {
x509Certificate,
entityId: this.spEntityId,
});
const userProfileGuard = z.record(z.string().or(z.array(z.string())));
@ -135,38 +116,19 @@ class SamlConnector {
* @returns The SSO URL.
*/
async getSingleSignOnUrl(relayState: string) {
const {
entityId: entityID,
x509Certificate,
nameIdFormat,
signingAlgorithm,
} = await this.getSamlConfig();
const { x509Certificate } = await this.getSamlIdpMetadata();
const identityProvider = await this.getIdentityProvider();
try {
const idpMetadataXml = await this.getIdpXmlMetadata();
// Add SSO connector errors and replace connector errors.
assert(
idpMetadataXml,
new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
message: "Can not get identity provider's metadata, please check configuration.",
})
);
// eslint-disable-next-line new-cap
const identityProvider = saml.IdentityProvider({
metadata: idpMetadataXml,
});
// eslint-disable-next-line new-cap
const serviceProvider = saml.ServiceProvider({
entityID, // FIXME: @darcyYe
entityID: this.spEntityId,
relayState,
nameIDFormat: nameIdFormat,
signingCert: x509Certificate,
requestSignatureAlgorithm: signingAlgorithm,
authnRequestsSigned: identityProvider.entityMeta.isWantAuthnRequestsSigned(), // Should align with IdP setting.
assertionConsumerService: [
{
Location: this.acsUrl,
Location: this.assertionConsumerServiceUrl,
Binding: saml.Constants.BindingNamespace.Post,
},
],
@ -185,16 +147,85 @@ class SamlConnector {
*
* @returns The raw SAML metadata in XML-format.
*/
private async getIdpXmlMetadata() {
if (this._rawSamlMetadata) {
return this._rawSamlMetadata;
private async getIdpMetadataXml() {
if (this._samlIdpMetadataXml) {
return this._samlIdpMetadataXml;
}
const rawSamlMetadata = await getRawSamlMetadata(this.config);
if (rawSamlMetadata) {
this._rawSamlMetadata = rawSamlMetadata;
this._samlIdpMetadataXml = await getSamlMetadataXml(this.config);
return this._samlIdpMetadataXml;
}
/**
* Get the manually filled SAML IdP metadata from the raw SAML SSO connector config (including `signInEndpoint` and `x509Certificate`).
*
* @returns Manually filled SAML IdP metadata.
*/
private getIdpMetadataJson() {
// Required fields of metadata should not be undefined.
const result = samlIdentityProviderMetadataGuard.safeParse(this.config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
return this._rawSamlMetadata;
return result.data;
}
/**
* Get identity provider constructed using `metadata` got from `config`.
*
* @returns Identity provider instance.
*/
private async getIdentityProvider() {
if (this._identityProvider) {
return this._identityProvider;
}
const idpMetadataXml = await this.getIdpMetadataXml();
if (idpMetadataXml) {
// eslint-disable-next-line new-cap
this._identityProvider = saml.IdentityProvider({
metadata: idpMetadataXml,
});
return this._identityProvider;
}
const idpMetadataJson = this.getIdpMetadataJson();
const { entityId: entityID, signInEndpoint, x509Certificate } = idpMetadataJson;
// eslint-disable-next-line new-cap
this._identityProvider = saml.IdentityProvider({
entityID,
signingCert: x509Certificate,
/**
* When `metadata` is not provided, `signInEndpoint` and `x509Certificate` are ensured by previous guard.
* We only support redirect binding for now when sending SAML auth request.
*/
singleSignOnService: [
{
Location: signInEndpoint,
Binding: saml.Constants.BindingNamespace.Redirect,
},
],
});
return this._identityProvider;
}
/**
* Get SAML IdP config along with parsed metadata from raw SAML SSO connector config.
*
* @remarks If this function can successfully get the SAML metadata, then it guarantees that the SAML identity provider instance is initiated.
*
* @returns Parsed SAML config along with it's parsed metadata.
*/
private async getSamlIdpMetadata(): Promise<SamlIdentityProviderMetadata> {
if (this._samlIdpMetadata) {
return this._samlIdpMetadata;
}
const identityProvider = await this.getIdentityProvider();
this._samlIdpMetadata = parseXmlMetadata(identityProvider);
return this._samlIdpMetadata;
}
}

View file

@ -1,34 +1,36 @@
import * as validator from '@authenio/samlify-node-xmllint';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { type Optional, conditional } from '@silverhand/essentials';
import { type GlobalValues } from '@logto/shared';
import { type Optional, conditional, appendPath } from '@silverhand/essentials';
import { got } from 'got';
import * as saml from 'samlify';
import { z } from 'zod';
import { ssoPath } from '#src/routes/interaction/const.js';
import {
samlMetadataGuard,
type SamlMetadata,
type SamlConnectorConfig,
type SamlConfig,
defaultAttributeMapping,
type CustomizableAttributeMap,
type AttributeMap,
extendedSocialUserInfoGuard,
type ExtendedSocialUserInfo,
type SamlIdentityProviderMetadata,
samlIdentityProviderMetadataGuard,
} from '../types/saml.js';
type ESamlHttpRequest = Parameters<saml.ServiceProviderInstance['parseLoginResponse']>[2];
/**
* Parse XML-format raw SAML metadata and return the parsed SAML metadata.
* Return the parsed SAML metadata using SAML identity provider initiated with SAML metadata.
*
* @param xml Raw SAML metadata in XML format.
* @returns The parsed SAML metadata.
* @param idP SAML identity provider instance.
*
* @returns The parsed SAML IdP metadata.
*/
export const parseXmlMetadata = (xml: string): SamlMetadata => {
// eslint-disable-next-line new-cap
const idP = saml.IdentityProvider({ metadata: xml });
export const parseXmlMetadata = (
idP: saml.IdentityProviderInstance
): SamlIdentityProviderMetadata => {
// Used to check whether xml content is valid in format.
saml.setSchemaValidator(validator);
@ -45,20 +47,15 @@ export const parseXmlMetadata = (xml: string): SamlMetadata => {
const rawSamlMetadata = {
entityId: idP.entityMeta.getEntityID(),
/**
* See implementation in `samlify` {@link https://github.com/tngan/samlify/blob/55f845da60b18d40668885c7f7e71ed0967ef67f/src/entity.ts#L88}.
*/
nameIdFormat: idP.entitySetting.nameIDFormat,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
signInEndpoint: singleSignOnService,
signingAlgorithm: idP.entitySetting.requestSignatureAlgorithm,
// The type inference of the return type of `getX509Certificate` is any, will be guarded by later zod parser if it is not string-typed.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
x509Certificate: idP.entityMeta.getX509Certificate(saml.Constants.wording.certUse.signing),
};
// The return type of `samlify`
const result = samlMetadataGuard.safeParse(rawSamlMetadata);
const result = samlIdentityProviderMetadataGuard.safeParse(rawSamlMetadata);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
@ -73,7 +70,7 @@ export const parseXmlMetadata = (xml: string): SamlMetadata => {
* @param config The raw SAML SSO connector config.
* @returns The corresponding IdP's raw SAML metadata (in XML format).
*/
export const getRawSamlMetadata = async (
export const getSamlMetadataXml = async (
config: SamlConnectorConfig
): Promise<Optional<string>> => {
const { metadata, metadataUrl } = config;
@ -129,19 +126,16 @@ export const getExtendedUserInfoFromRawUserProfile = (
* Handle the SAML assertion from the identity provider.
*
* @param request The SAML assertion sent by IdP (after getting the SAML auth request).
* @param config The full config of the SAML SSO connector.
* @param identityProvider The SAML identity provider instance (where we can get parsed IdP metadata).
* @param metadata The selected part of metadata of the SAML SSO connector.
* @returns The returned info contained in the SAML assertion.
*/
export const handleSamlAssertion = async (
request: ESamlHttpRequest,
config: SamlConfig & { idpMetadataXml: string }
identityProvider: saml.IdentityProviderInstance,
metadata: { entityId: string; x509Certificate: string }
): Promise<Record<string, unknown>> => {
const { entityId: entityID, x509Certificate, idpMetadataXml } = config;
// eslint-disable-next-line new-cap
const identityProvider = saml.IdentityProvider({
metadata: idpMetadataXml,
});
const { entityId: entityID, x509Certificate } = metadata;
// eslint-disable-next-line new-cap
const serviceProvider = saml.ServiceProvider({
@ -185,3 +179,37 @@ export const attributeMappingPostProcessor = (
...conditional(attributeMapping && attributeMapping),
};
};
/**
* Generate the entity id for the current SAML SSO connector using admin console path, current tenant id and connector id.
* Used URL-like entity id here since some identity providers will check the format of the entity id.
* See {@link https://spaces.at.internet2.edu/display/federation/saml-metadata-entityid} to know more details about how should `entityId` look like.
*
* @param globalValues Global setups
* @param tenantId Current tenant id.
* @param connectorId Current connector id.
*
* @returns Entity id for the current SAML SSO connector.
*/
export const buildSpEntityId = (
globalValues: GlobalValues,
tenantId: string,
connectorId: string
) => {
const { isCloud, cloudUrlSet, adminUrlSet } = globalValues;
return appendPath(
isCloud ? cloudUrlSet.endpoint : adminUrlSet.endpoint,
tenantId,
`/enterprise-sso/${connectorId}`
).toString();
};
/**
* Generate the assertion consumer service url for the current SAML SSO connector using current base url and connector id.
*
* @param baseUrl Base endpoint for the current service
* @param ssoConnectorId Current enterprise SSO connector id
* @returns
*/
export const buildAssertionConsumerServiceUrl = (baseUrl: URL, ssoConnectorId: string) =>
appendPath(baseUrl, `api/authn/${ssoPath}/saml/${ssoConnectorId}`).toString();

View file

@ -38,7 +38,9 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn {
}
async getIssuer() {
const { entityId } = await this.getSamlConfig();
const {
serviceProvider: { entityId },
} = await this.getSamlConfig();
return entityId;
}

View file

@ -1,5 +1,4 @@
import { socialUserInfoGuard } from '@logto/connector-kit';
import { jsonGuard } from '@logto/schemas';
import { z } from 'zod';
// Since the SAML SSO user info will extend the basic social user info (will contain extra info like `organization`, `role` etc.), but for now we haven't decide what should be included in extended user info, so we just use the basic social user info guard here to keep SSOT.
@ -14,33 +13,45 @@ const customizableAttributeMappingGuard = samlAttributeMappingGuard.partial();
export type CustomizableAttributeMap = z.infer<typeof customizableAttributeMappingGuard>;
export type AttributeMap = Required<CustomizableAttributeMap>;
export const samlConnectorConfigGuard = z
.object({
/**
* This is the metadata of SAML service provider, automatically generated by `tenantId` and `ssoConnectorId`.
* See details in {@link @logto/core/src/sso/SamlConnector/index.ts}.
*/
const samlServiceProviderMetadataGuard = z.object({
entityId: z.string().min(1),
assertionConsumerServiceUrl: z.string().min(1),
});
export type SamlServiceProviderMetadata = z.infer<typeof samlServiceProviderMetadataGuard>;
/**
* We only concern about the `entityId`, `signInEndpoint` and `x509Certificate` for now.
*/
export const samlIdentityProviderMetadataGuard = z.object({
entityId: z.string(),
signInEndpoint: z.string(),
x509Certificate: z.string(),
});
export type SamlIdentityProviderMetadata = z.infer<typeof samlIdentityProviderMetadataGuard>;
export const samlConnectorConfigGuard = samlIdentityProviderMetadataGuard
.extend({
attributeMapping: customizableAttributeMappingGuard,
signInEndpoint: z.string(),
entityId: z.string(),
x509Certificate: z.string(),
metadataUrl: z.string(),
metadata: z.string(),
metadataUrl: z.string(),
})
.partial();
export type SamlConnectorConfig = z.infer<typeof samlConnectorConfigGuard>;
export const samlMetadataGuard = z
.object({
entityId: z.string(),
nameIdFormat: z.string().array().optional(),
signInEndpoint: z.string(),
signingAlgorithm: z.string().optional(),
x509Certificate: z.string(),
})
.catchall(jsonGuard); // Allow extra fields, also need to fit the `JsonObject` type.
const samlMetadataGuard = z.object({
serviceProvider: samlServiceProviderMetadataGuard,
identityProvider: samlIdentityProviderMetadataGuard,
});
export type SamlMetadata = z.infer<typeof samlMetadataGuard>;
export type SamlConfig = SamlConnectorConfig & SamlMetadata;
// Saml assertion returned user attribute value
export const extendedSocialUserInfoGuard = socialUserInfoGuard.catchall(z.unknown());
export type ExtendedSocialUserInfo = z.infer<typeof extendedSocialUserInfoGuard>;

View file

@ -13,6 +13,7 @@ import {
patchSsoConnectorById,
patchSsoConnectorConfigById,
} from '#src/api/sso-connector.js';
import { SsoProviderName } from '#src/constants.js';
describe('sso-connector library', () => {
it('should return sso-connector-factories', async () => {
@ -80,7 +81,7 @@ describe('post sso-connectors', () => {
connectorName: 'test',
config: {
issuer: 23,
entityId: 123,
signInEndpoint: 123,
},
})
).rejects.toThrow(HTTPError);
@ -236,7 +237,7 @@ describe('patch sso-connector by id', () => {
patchSsoConnectorById(id, {
config: {
issuer: 23,
entityId: 123,
signInEndpoint: 123,
},
})
).rejects.toThrow(HTTPError);
@ -264,6 +265,11 @@ describe('patch sso-connector by id', () => {
expect(connector).toHaveProperty('config', config);
expect(connector).toHaveProperty('syncProfile', true);
// Since we've provided a valid metadata content, check if the `providerConfig` is returned.
if (providerName === SsoProviderName.SAML) {
expect(connector.providerConfig).toBeDefined();
}
await deleteSsoConnectorById(id);
}
);
@ -287,7 +293,7 @@ describe('patch sso-connector config by id', () => {
await expect(
patchSsoConnectorConfigById(id, {
issuer: 23,
entityId: 123,
signInEndpoint: 123,
})
).rejects.toThrow(HTTPError);