From 40a5a18d9084da0bdb2469969e260c089ad5fdc1 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 2 Nov 2023 11:17:34 +0800 Subject: [PATCH 1/2] feat(core): add saml sso class --- packages/core/package.json | 2 + .../core/src/include.d/xml-validator.d.ts | 3 + .../src/routes/sso-connector/utils.test.ts | 6 +- packages/core/src/sso/OidcConnector/index.ts | 5 + packages/core/src/sso/SamlConnector/index.ts | 198 ++++++++++++++++++ .../core/src/sso/SamlConnector/utils.test.ts | 69 ++++++ packages/core/src/sso/SamlConnector/utils.ts | 189 +++++++++++++++++ .../src/sso/SamlSsoConnector/index.test.ts | 32 +++ .../core/src/sso/SamlSsoConnector/index.ts | 74 +++++++ packages/core/src/sso/index.ts | 12 +- packages/core/src/sso/types/index.ts | 1 + packages/core/src/sso/types/saml.ts | 42 ++++ pnpm-lock.yaml | 20 ++ 13 files changed, 649 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/include.d/xml-validator.d.ts create mode 100644 packages/core/src/sso/SamlConnector/index.ts create mode 100644 packages/core/src/sso/SamlConnector/utils.test.ts create mode 100644 packages/core/src/sso/SamlConnector/utils.ts create mode 100644 packages/core/src/sso/SamlSsoConnector/index.test.ts create mode 100644 packages/core/src/sso/SamlSsoConnector/index.ts create mode 100644 packages/core/src/sso/types/saml.ts diff --git a/packages/core/package.json b/packages/core/package.json index 51eadc3b9..6df01e1de 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "test:report": "codecov -F core" }, "dependencies": { + "@authenio/samlify-xsd-schema-validator": "^1.0.5", "@aws-sdk/client-s3": "^3.315.0", "@azure/storage-blob": "^12.13.0", "@google-cloud/storage": "^7.3.0", @@ -80,6 +81,7 @@ "redis": "^4.6.5", "roarr": "^7.11.0", "semver": "^7.3.8", + "samlify": "2.8.10", "slonik": "^30.0.0", "slonik-interceptor-preset": "^1.2.10", "slonik-sql-tag-raw": "^1.1.4", diff --git a/packages/core/src/include.d/xml-validator.d.ts b/packages/core/src/include.d/xml-validator.d.ts new file mode 100644 index 000000000..b5b414777 --- /dev/null +++ b/packages/core/src/include.d/xml-validator.d.ts @@ -0,0 +1,3 @@ +declare module '@authenio/samlify-xsd-schema-validator' { + export declare const validate: (xml: string) => Promise; +} diff --git a/packages/core/src/routes/sso-connector/utils.test.ts b/packages/core/src/routes/sso-connector/utils.test.ts index da657d21a..7fb531d8d 100644 --- a/packages/core/src/routes/sso-connector/utils.test.ts +++ b/packages/core/src/routes/sso-connector/utils.test.ts @@ -51,7 +51,7 @@ describe('fetchConnectorProviderDetails', () => { expect(result).toEqual({ ...connector, - providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo, + providerLogo: ssoConnectorFactories[connector.providerName].logo, }); expect(fetchOidcConfig).not.toBeCalled(); @@ -68,7 +68,7 @@ describe('fetchConnectorProviderDetails', () => { expect(result).toEqual({ ...connector, - providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo, + providerLogo: ssoConnectorFactories[connector.providerName].logo, }); expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer); @@ -85,7 +85,7 @@ describe('fetchConnectorProviderDetails', () => { expect(result).toEqual({ ...connector, - providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo, + providerLogo: ssoConnectorFactories[connector.providerName].logo, providerConfig: { ...connector.config, scope: 'openid', // Default scope diff --git a/packages/core/src/sso/OidcConnector/index.ts b/packages/core/src/sso/OidcConnector/index.ts index 39bed5162..8e390e6b7 100644 --- a/packages/core/src/sso/OidcConnector/index.ts +++ b/packages/core/src/sso/OidcConnector/index.ts @@ -40,6 +40,11 @@ class OidcConnector { }; }; + /** `Issuer` will be used by SSO identity to indicate the source of the identity */ + async getIssuer() { + return this.config.issuer; + } + /** * Generate the authorization URL for the OIDC provider * diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts new file mode 100644 index 000000000..5baad8639 --- /dev/null +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -0,0 +1,198 @@ +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; +import { assert, appendPath, conditional, type Optional } from '@silverhand/essentials'; +import * as saml from 'samlify'; +import { z } from 'zod'; + +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; + +import { type SamlConfig, type SamlConnectorConfig, samlMetadataGuard } from '../types/saml.js'; + +import { + parseXmlMetadata, + getRawSamlMetadata, + handleSamlAssertion, + attributeMappingPostProcessor, + getExtendedUserInfoFromRawUserProfile, +} from './utils.js'; + +/** + * SAML connector + * + * @remark General connector for SAML protocol. + * This class provides the basic functionality to connect with a SAML IdP. + * 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 + * @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) + * + * @method getSamlConfig Parse and return SAML config from the XML-format metadata. 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. + */ +class SamlConnector { + readonly acsUrl: string; + private _rawSamlMetadata: Optional; + private _parsedSamlMetadata: Optional; + + constructor( + private readonly config: SamlConnectorConfig, + tenantId: string, + ssoConnectorId: string + ) { + this.acsUrl = appendPath( + getTenantEndpoint(tenantId, EnvSet.values), + // TODO: update this endpoint + `api/authn/saml/sso/${ssoConnectorId}` + ).toString(); + } + + /** + * Get SAML config along with parsed metadata from raw SAML SSO connector config. + * + * @returns Parsed SAML config along with it's parsed metadata. + */ + async getSamlConfig(): Promise { + 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; + } + + /** + * Parse and return the SAML assertion from IdP (with attribute mapping applied). + * + * @param assertion The SAML assertion from IdP. + * + * @returns The parsed SAML assertion from IdP (with attribute mapping applied). + */ + async parseSamlAssertion(assertion: Record) { + const parsedConfig = await this.getSamlConfig(); + const profileMap = attributeMappingPostProcessor(parsedConfig.attributeMapping); + 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.", + }) + ); + + const samlAssertionContent = await handleSamlAssertion(assertion, { + ...parsedConfig, + idpMetadataXml, + }); + + const userProfileGuard = z.record(z.string().or(z.array(z.string()))); + const rawProfileParseResult = userProfileGuard.safeParse(samlAssertionContent); + + if (!rawProfileParseResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error); + } + + const rawUserProfile = rawProfileParseResult.data; + + return getExtendedUserInfoFromRawUserProfile(rawUserProfile, profileMap); + } + + /** + * Get the SSO URL. + * + * @param jti The current session id. + * + * @returns The SSO URL. + */ + async getSingleSignOnUrl(jti: string) { + const { + entityId: entityID, + x509Certificate, + nameIdFormat, + signingAlgorithm, + } = await this.getSamlConfig(); + + 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({ + wantAuthnRequestsSigned: true, // Sign auth request by default + metadata: idpMetadataXml, + }); + // eslint-disable-next-line new-cap + const serviceProvider = saml.ServiceProvider({ + entityID, + relayState: jti, + nameIDFormat: nameIdFormat, + signingCert: x509Certificate, + authnRequestsSigned: true, // Sign auth request by default + requestSignatureAlgorithm: signingAlgorithm, + assertionConsumerService: [ + { + Location: this.acsUrl, + Binding: saml.Constants.BindingNamespace.Post, + }, + ], + }); + + const loginRequest = serviceProvider.createLoginRequest(identityProvider, 'redirect'); + + return loginRequest.context; + } catch (error: unknown) { + throw new ConnectorError(ConnectorErrorCodes.General, error); + } + } + + /** + * Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config. + * + * @returns The raw SAML metadata in XML-format. + */ + private async getIdpXmlMetadata() { + if (this._rawSamlMetadata) { + return this._rawSamlMetadata; + } + + const rawSamlMetadata = await getRawSamlMetadata(this.config); + if (rawSamlMetadata) { + this._rawSamlMetadata = rawSamlMetadata; + } + return this._rawSamlMetadata; + } +} + +export default SamlConnector; diff --git a/packages/core/src/sso/SamlConnector/utils.test.ts b/packages/core/src/sso/SamlConnector/utils.test.ts new file mode 100644 index 000000000..fcff97089 --- /dev/null +++ b/packages/core/src/sso/SamlConnector/utils.test.ts @@ -0,0 +1,69 @@ +import { attributeMappingPostProcessor, getExtendedUserInfoFromRawUserProfile } from './utils.js'; + +const expectedDefaultAttributeMapping = { + id: 'id', + email: 'email', + phone: 'phone', + name: 'name', + avatar: 'avatar', +}; + +describe('attributeMappingPostProcessor', () => { + it('should fallback to `expectedDefaultAttributeMapping` if no other attribute mapping is specified', () => { + expect(attributeMappingPostProcessor()).toEqual(expectedDefaultAttributeMapping); + expect(attributeMappingPostProcessor({})).toEqual(expectedDefaultAttributeMapping); + }); + + it('should overwrite specified attributes of `expectedDefaultAttributeMapping`', () => { + expect(attributeMappingPostProcessor({ id: 'sub', avatar: 'picture' })).toEqual({ + ...expectedDefaultAttributeMapping, + id: 'sub', + avatar: 'picture', + }); + }); +}); + +describe('getExtendedUserInfoFromRawUserProfile', () => { + it('should correctly map even if attributeMap is not specified', () => { + const keyMapping = attributeMappingPostProcessor(); + const rawUserProfile = { + id: 'foo', + picture: 'pic.png', + }; + expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual( + rawUserProfile + ); + }); + + it('should correctly map with specific fields specified', () => { + const keyMapping = attributeMappingPostProcessor({ id: 'sub' }); + const rawUserProfile = { + sub: 'foo', + avatar: 'pic.png', + }; + expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual({ + id: 'foo', + avatar: 'pic.png', + }); + }); + + it('should correctly map with specific fields specified and with extended fields unchanged', () => { + const keyMapping = attributeMappingPostProcessor({ phone: 'cell_phone', avatar: 'picture' }); + const rawUserProfile = { + id: 'foo', + sub: 'bar', + email: 'test@logto.io', + cell_phone: '123456789', + picture: 'pic.png', + extend_field: 'extend_field', + }; + expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual({ + id: 'foo', + sub: 'bar', + email: 'test@logto.io', + phone: '123456789', + avatar: 'pic.png', + extend_field: 'extend_field', + }); + }); +}); diff --git a/packages/core/src/sso/SamlConnector/utils.ts b/packages/core/src/sso/SamlConnector/utils.ts new file mode 100644 index 000000000..17c52aaaf --- /dev/null +++ b/packages/core/src/sso/SamlConnector/utils.ts @@ -0,0 +1,189 @@ +import * as validator from '@authenio/samlify-xsd-schema-validator'; +import { ConnectorError, ConnectorErrorCodes, socialUserInfoGuard } from '@logto/connector-kit'; +import { type Optional, conditional } from '@silverhand/essentials'; +import { got } from 'got'; +import * as saml from 'samlify'; +import { z } from 'zod'; + +import { + samlMetadataGuard, + type SamlMetadata, + type SamlConnectorConfig, + type SamlConfig, + defaultAttributeMapping, + type CustomizableAttributeMap, + type AttributeMap, +} from '../types/saml.js'; + +type ESamlHttpRequest = Parameters[2]; + +const extendedSocialUserInfoGuard = socialUserInfoGuard.catchall(z.unknown()); + +type ExtendedSocialUserInfo = z.infer; + +/** + * Parse XML-format raw SAML metadata and return the parsed SAML metadata. + * + * @param xml Raw SAML metadata in XML format. + * @returns The parsed SAML metadata. + */ +export const parseXmlMetadata = (xml: string): SamlMetadata => { + // eslint-disable-next-line new-cap + const idP = saml.IdentityProvider({ metadata: xml }); + + // Used to check whether xml content is valid in format. + saml.setSchemaValidator(validator); + + const rawSingleSignOnService = idP.entityMeta.getSingleSignOnService( + saml.Constants.namespace.binding.redirect + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const singleSignOnService = + typeof rawSingleSignOnService === 'string' + ? rawSingleSignOnService + : Object.entries(rawSingleSignOnService).find( + ([key, _]) => key === saml.Constants.namespace.binding.redirect + )?.[1]; + + 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); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); + } + + return result.data; +}; + +/** + * Get corresponding IdP's raw SAML metadata (in XML format) from the SAML SSO connector config. + * + * @param config The raw SAML SSO connector config. + * @returns The corresponding IdP's raw SAML metadata (in XML format). + */ +export const getRawSamlMetadata = async ( + config: SamlConnectorConfig +): Promise> => { + const { metadata, metadataUrl } = config; + if (metadataUrl) { + try { + const { body } = await got.get(metadataUrl); + + const result = z.string().safeParse(body); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); + } + + return result.data; + } catch (error: unknown) { + // HTTP request error + throw new ConnectorError(ConnectorErrorCodes.General, error); + } + } + + return metadata; +}; + +/** + * Get the user info from the raw user profile extracted from IdP SAML assertion. + * + * @param rawUserProfile The raw user profile extracted from IdP SAML assertion. + * @param keyMapping The full attribute mapping with default values. + * @returns The mapped social user info. + */ +export const getExtendedUserInfoFromRawUserProfile = ( + rawUserProfile: Record, + keyMapping: AttributeMap +): ExtendedSocialUserInfo => { + const keyMap = new Map( + Object.entries(keyMapping).map(([destination, source]) => [source, destination]) + ); + + const mappedUserProfile = Object.fromEntries( + Object.entries(rawUserProfile).map(([key, value]) => [keyMap.get(key) ?? key, value]) + ); + + const result = extendedSocialUserInfoGuard.safeParse(mappedUserProfile); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + } + + return result.data; +}; + +/** + * 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. + * @returns The returned info contained in the SAML assertion. + */ +export const handleSamlAssertion = async ( + request: ESamlHttpRequest, + config: SamlConfig & { idpMetadataXml: string } +): Promise> => { + const { entityId: entityID, x509Certificate, idpMetadataXml } = config; + + // eslint-disable-next-line new-cap + const identityProvider = saml.IdentityProvider({ + metadata: idpMetadataXml, + }); + + // eslint-disable-next-line new-cap + const serviceProvider = saml.ServiceProvider({ + entityID, + signingCert: x509Certificate, + }); + + // Used to check whether xml content is valid in format. + saml.setSchemaValidator(validator); + + try { + const assertionResult = await serviceProvider.parseLoginResponse( + identityProvider, + 'post', + request + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...(Boolean(assertionResult.extract.nameID) && { + id: assertionResult.extract.nameID, + }), + ...assertionResult.extract.attributes, + }; + } catch (error: unknown) { + throw new ConnectorError(ConnectorErrorCodes.General, String(error)); + } +}; + +/** + * Get the full attribute mapping using specified attribute mappings with default fallback values. + * + * @param attributeMapping Specified attribute mapping stored in database. + * @returns Full attribute mapping with default values. + */ +export const attributeMappingPostProcessor = ( + attributeMapping?: CustomizableAttributeMap +): AttributeMap => { + return { + ...defaultAttributeMapping, + ...conditional(attributeMapping && attributeMapping), + }; +}; diff --git a/packages/core/src/sso/SamlSsoConnector/index.test.ts b/packages/core/src/sso/SamlSsoConnector/index.test.ts new file mode 100644 index 000000000..33e59c73c --- /dev/null +++ b/packages/core/src/sso/SamlSsoConnector/index.test.ts @@ -0,0 +1,32 @@ +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; + +import { mockSsoConnector as _mockSsoConnector } from '#src/__mocks__/sso.js'; + +import { SsoProviderName } from '../types/index.js'; + +import { samlSsoConnectorFactory } from './index.js'; + +const mockSsoConnector = { ..._mockSsoConnector, providerName: SsoProviderName.SAML }; + +describe('SamlSsoConnector', () => { + it('SamlSsoConnector should contains static properties', () => { + expect(samlSsoConnectorFactory.providerName).toEqual(SsoProviderName.SAML); + expect(samlSsoConnectorFactory.configGuard).toBeDefined(); + }); + + it('constructor should throw error if config is invalid', () => { + const result = samlSsoConnectorFactory.configGuard.safeParse(mockSsoConnector.config); + + if (result.success) { + throw new Error('Invalid config'); + } + + const createSamlSsoConnector = () => { + return new samlSsoConnectorFactory.constructor(mockSsoConnector, 'http://localhost:3001/api'); + }; + + expect(createSamlSsoConnector).toThrow( + new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error) + ); + }); +}); diff --git a/packages/core/src/sso/SamlSsoConnector/index.ts b/packages/core/src/sso/SamlSsoConnector/index.ts new file mode 100644 index 000000000..2f1cf868f --- /dev/null +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -0,0 +1,74 @@ +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; +import { type SsoConnector } from '@logto/schemas'; + +import SamlConnector from '../SamlConnector/index.js'; +import { type SingleSignOnFactory } from '../index.js'; +import { type SingleSignOn, SsoProviderName } from '../types/index.js'; +import { samlConnectorConfigGuard } from '../types/saml.js'; + +/** + * SAML SSO connector + * + * This class extends the basic SAML connector class and add some business related utils methods. + * + * @property data The SAML connector data from the database + * + * @method getConfig Get parsed SAML config along with it's metadata. Throws error if config is invalid. + * @method getAuthorizationUrl Get SAML auth URL. + * @method getUserInfo Get social user info. + */ +export class SamlSsoConnector extends SamlConnector implements SingleSignOn { + constructor( + readonly data: SsoConnector, + tenantId: string + ) { + const parseConfigResult = samlConnectorConfigGuard.safeParse(data.config); + + if (!parseConfigResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error); + } + + super(parseConfigResult.data, tenantId, data.id); + } + + /** + * Get parsed SAML connector's config along with it's metadata. Throws error if config is invalid. + * + * @returns Parsed SAML connector config and it's metadata. + */ + async getConfig() { + return this.getSamlConfig(); + } + + /** + * Get social user info. + * + * @param assertion The SAML assertion from IdP. + * + * @returns The social user info extracted from SAML assertion. + */ + async getUserInfo(assertion: Record) { + return this.parseSamlAssertion(assertion); + } + + /** + * Get SAML auth URL. + * + * @param jti The current session id. + * + * @returns The SAML auth URL. + */ + async getAuthorizationUrl(jti: string) { + return this.getSingleSignOnUrl(jti); + } +} + +export const samlSsoConnectorFactory: SingleSignOnFactory = { + providerName: SsoProviderName.SAML, + logo: 'saml.svg', + description: { + en: ' This connector is used to connect to SAML single sign-on identity provider.', + }, + configGuard: samlConnectorConfigGuard, + constructor: SamlSsoConnector, +}; diff --git a/packages/core/src/sso/index.ts b/packages/core/src/sso/index.ts index b57ca7264..a023fcb0f 100644 --- a/packages/core/src/sso/index.ts +++ b/packages/core/src/sso/index.ts @@ -1,15 +1,21 @@ import { type I18nPhrases } from '@logto/connector-kit'; import { oidcSsoConnectorFactory, type OidcSsoConnector } from './OidcSsoConnector/index.js'; +import { type SamlSsoConnector, samlSsoConnectorFactory } from './SamlSsoConnector/index.js'; import { SsoProviderName } from './types/index.js'; import { type basicOidcConnectorConfigGuard } from './types/oidc.js'; +import { type samlConnectorConfigGuard } from './types/saml.js'; type SingleSignOnConstructor = T extends SsoProviderName.OIDC ? typeof OidcSsoConnector + : T extends SsoProviderName.SAML + ? typeof SamlSsoConnector : never; type SingleSignOnConnectorConfig = T extends SsoProviderName.OIDC ? typeof basicOidcConnectorConfigGuard + : T extends SsoProviderName.SAML + ? typeof samlConnectorConfigGuard : never; export type SingleSignOnFactory = { @@ -24,6 +30,10 @@ export const ssoConnectorFactories: { [key in SsoProviderName]: SingleSignOnFactory; } = { [SsoProviderName.OIDC]: oidcSsoConnectorFactory, + [SsoProviderName.SAML]: samlSsoConnectorFactory, }; -export const standardSsoConnectorProviders = Object.freeze([SsoProviderName.OIDC]); +export const standardSsoConnectorProviders = Object.freeze([ + SsoProviderName.OIDC, + SsoProviderName.SAML, +]); diff --git a/packages/core/src/sso/types/index.ts b/packages/core/src/sso/types/index.ts index eda242fd8..077cd6a0b 100644 --- a/packages/core/src/sso/types/index.ts +++ b/packages/core/src/sso/types/index.ts @@ -14,6 +14,7 @@ export abstract class SingleSignOn { export enum SsoProviderName { OIDC = 'OIDC', + SAML = 'SAML', } export type SupportedSsoConnector = Omit & { diff --git a/packages/core/src/sso/types/saml.ts b/packages/core/src/sso/types/saml.ts new file mode 100644 index 000000000..93577d3e7 --- /dev/null +++ b/packages/core/src/sso/types/saml.ts @@ -0,0 +1,42 @@ +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. +const samlAttributeMappingGuard = socialUserInfoGuard; + +// eslint-disable-next-line no-restricted-syntax +export const defaultAttributeMapping = Object.fromEntries( + Object.keys(samlAttributeMappingGuard.shape).map((key) => [key, key]) +) as AttributeMap; + +const customizableAttributeMappingGuard = samlAttributeMappingGuard.partial(); +export type CustomizableAttributeMap = z.infer; +export type AttributeMap = Required; + +export const samlConnectorConfigGuard = z + .object({ + attributeMapping: customizableAttributeMappingGuard, + signInEndpoint: z.string(), + entityId: z.string(), + x509Certificate: z.string(), + metadataUrl: z.string(), + metadata: z.string(), + }) + .partial(); + +export type SamlConnectorConfig = z.infer; + +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. + +export type SamlMetadata = z.infer; + +export type SamlConfig = SamlConnectorConfig & SamlMetadata; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79f23cfd5..37da2546d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3121,6 +3121,9 @@ importers: packages/core: dependencies: + '@authenio/samlify-xsd-schema-validator': + specifier: ^1.0.5 + version: 1.0.5(samlify@2.8.10) '@aws-sdk/client-s3': specifier: ^3.315.0 version: 3.315.0 @@ -3283,6 +3286,9 @@ importers: roarr: specifier: ^7.11.0 version: 7.11.0 + samlify: + specifier: 2.8.10 + version: 2.8.10 semver: specifier: ^7.3.8 version: 7.3.8 @@ -4165,6 +4171,15 @@ packages: '@jridgewell/trace-mapping': 0.3.18 dev: true + /@authenio/samlify-xsd-schema-validator@1.0.5(samlify@2.8.10): + resolution: {integrity: sha512-HJjmjM1WbeB/z4nVbYEcmtIWTLPKqjrqRGEpC9lu7s03Usc4nxxfrJGjHgh3M8MvBJy4neVUoeM9rP4ym3GLgg==} + peerDependencies: + samlify: '>= 2.6.0' + dependencies: + '@authenio/xsd-schema-validator': 0.7.3 + samlify: 2.8.10 + dev: false + /@authenio/xml-encryption@2.0.2: resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} engines: {node: '>=12'} @@ -4174,6 +4189,11 @@ packages: xpath: 0.0.32 dev: false + /@authenio/xsd-schema-validator@0.7.3: + resolution: {integrity: sha512-Jhc/Hxv90bacZr0Fv+u+PEb440zPh4mO6rw+bzEAIBiFLKCtRa/BvKGRxPdCAwsGRPuwl2hFqQGF+Lfz6Q8kFg==} + requiresBuild: true + dev: false + /@aws-crypto/crc32@3.0.0: resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} dependencies: From 7fe41a0037070c3cc4ba0fae7718a233ae66253b Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 7 Nov 2023 21:12:29 +0800 Subject: [PATCH 2/2] feat(core,test): update sso connector util functions, APIs and integration tests (#4807) --- packages/core/package.json | 2 +- .../core/src/include.d/xml-validator.d.ts | 3 - .../src/routes/interaction/single-sign-on.ts | 24 ++- .../core/src/routes/sso-connector/index.ts | 28 +-- .../src/routes/sso-connector/utils.test.ts | 53 ++--- .../core/src/routes/sso-connector/utils.ts | 7 +- packages/core/src/sso/SamlConnector/utils.ts | 2 +- .../src/sso/SamlSsoConnector/index.test.ts | 13 +- .../core/src/sso/SamlSsoConnector/index.ts | 12 -- .../src/__mocks__/sso-connectors-mock.ts | 29 +++ packages/integration-tests/src/api/api.ts | 1 + .../src/tests/api/sso-connectors.test.ts | 202 +++++++++--------- pnpm-lock.yaml | 21 +- 13 files changed, 214 insertions(+), 183 deletions(-) delete mode 100644 packages/core/src/include.d/xml-validator.d.ts create mode 100644 packages/integration-tests/src/__mocks__/sso-connectors-mock.ts diff --git a/packages/core/package.json b/packages/core/package.json index 6df01e1de..2cfac5e3a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ "test:report": "codecov -F core" }, "dependencies": { - "@authenio/samlify-xsd-schema-validator": "^1.0.5", + "@authenio/samlify-node-xmllint": "^2.0.0", "@aws-sdk/client-s3": "^3.315.0", "@azure/storage-blob": "^12.13.0", "@google-cloud/storage": "^7.3.0", diff --git a/packages/core/src/include.d/xml-validator.d.ts b/packages/core/src/include.d/xml-validator.d.ts deleted file mode 100644 index b5b414777..000000000 --- a/packages/core/src/include.d/xml-validator.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module '@authenio/samlify-xsd-schema-validator' { - export declare const validate: (xml: string) => Promise; -} diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index 3d610b980..163808543 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; @@ -22,6 +23,7 @@ export default function singleSignOnRoutes( tenant: TenantContext ) { const { + id: tenantId, provider, libraries: { ssoConnector }, } = tenant; @@ -74,20 +76,20 @@ export default function singleSignOnRoutes( try { // Will throw ConnectorError if the config is invalid - const connectorInstance = new ssoConnectorFactories[connectorData.providerName].constructor( - connectorData - ); + const factory = ssoConnectorFactories[connectorData.providerName]; + const connectorInstance = new factory.constructor(connectorData, tenantId); - // Will throw ConnectorError if failed to fetch the provider's config - const redirectTo = await connectorInstance.getAuthorizationUrl( - { state, redirectUri }, - async (connectorSession: ConnectorSession) => - assignConnectorSessionResult(ctx, provider, connectorSession) - ); + if (connectorInstance instanceof OidcSsoConnector) { + const redirectTo = await connectorInstance.getAuthorizationUrl( + { state, redirectUri }, + async (connectorSession: ConnectorSession) => + assignConnectorSessionResult(ctx, provider, connectorSession) + ); - // TODO: Add SAML connector support later + ctx.body = { redirectTo }; + } - ctx.body = { redirectTo }; + // TODO: Add SAML `getSingleSignOnUrl` here } catch (error: unknown) { // Catch ConnectorError and re-throw as 500 RequestError if (error instanceof ConnectorError) { diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index fb294521d..cafbc6db1 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -5,19 +5,19 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; -import { ssoConnectorFactories, standardSsoConnectorProviders } from '#src/sso/index.js'; -import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js'; -import { tableToPathname } from '#src/utils/SchemaRouter.js'; - -import { type AuthedRouter, type RouterInitArgs } from '../types.js'; - import { connectorFactoriesResponseGuard, type ConnectorFactoryDetail, ssoConnectorCreateGuard, ssoConnectorWithProviderConfigGuard, ssoConnectorPatchGuard, -} from './type.js'; +} from '#src/routes/sso-connector/type.js'; +import { ssoConnectorFactories, standardSsoConnectorProviders } from '#src/sso/index.js'; +import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js'; +import { tableToPathname } from '#src/utils/SchemaRouter.js'; + +import { type AuthedRouter, type RouterInitArgs } from '../types.js'; + import { parseFactoryDetail, parseConnectorConfig, @@ -28,13 +28,15 @@ export default function singleSignOnRoutes(...args: Rout const [ router, { - libraries: { ssoConnector: ssoConnectorLibrary }, + id: tenantId, queries: { ssoConnectors }, + libraries: { + ssoConnector: { getSsoConnectorById, getSsoConnectors }, + }, }, ] = args; const pathname = `/${tableToPathname(SsoConnectors.table)}`; - const { getSsoConnectorById, getSsoConnectors } = ssoConnectorLibrary; /* Get all supported single sign on connector factory details @@ -124,7 +126,7 @@ export default function singleSignOnRoutes(...args: Rout // Fetch provider details for each connector const connectorsWithProviderDetails = await Promise.all( - connectors.map(async (connector) => fetchConnectorProviderDetails(connector)) + connectors.map(async (connector) => fetchConnectorProviderDetails(connector, tenantId)) ); ctx.body = connectorsWithProviderDetails; @@ -147,7 +149,7 @@ export default function singleSignOnRoutes(...args: Rout const connector = await getSsoConnectorById(id); // Fetch provider details for the connector - const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector); + const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId); ctx.body = connectorWithProviderDetails; @@ -208,7 +210,7 @@ export default function singleSignOnRoutes(...args: Rout new RequestError({ code: 'connector.not_found', status: 404 }) ); - const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector); + const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId); ctx.body = connectorWithProviderDetails; @@ -248,7 +250,7 @@ export default function singleSignOnRoutes(...args: Rout ); // Fetch provider details for the connector - const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector); + const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId); ctx.body = connectorWithProviderDetails; diff --git a/packages/core/src/routes/sso-connector/utils.test.ts b/packages/core/src/routes/sso-connector/utils.test.ts index 7fb531d8d..ed3ff4f5e 100644 --- a/packages/core/src/routes/sso-connector/utils.test.ts +++ b/packages/core/src/routes/sso-connector/utils.test.ts @@ -14,6 +14,8 @@ await mockEsmWithActual('#src/sso/OidcConnector/utils.js', () => ({ const { ssoConnectorFactories } = await import('#src/sso/index.js'); const { parseFactoryDetail, fetchConnectorProviderDetails } = await import('./utils.js'); +const mockTenantId = 'mock_tenant_id'; + describe('parseFactoryDetail', () => { it.each(Object.values(SsoProviderName))('should return correct detail for %s', (providerName) => { const { logo, description } = ssoConnectorFactories[providerName]; @@ -43,16 +45,15 @@ describe('parseFactoryDetail', () => { describe('fetchConnectorProviderDetails', () => { it('providerConfig should be undefined if connector config is invalid', async () => { - const connector = { - ...mockSsoConnector, - config: { clientId: 'foo' }, - }; - const result = await fetchConnectorProviderDetails(connector); + const connector = { ...mockSsoConnector, config: { clientId: 'foo' } }; + const result = await fetchConnectorProviderDetails(connector, mockTenantId); - expect(result).toEqual({ - ...connector, - providerLogo: ssoConnectorFactories[connector.providerName].logo, - }); + expect(result).toMatchObject( + expect.objectContaining({ + ...connector, + providerLogo: ssoConnectorFactories[connector.providerName].logo, + }) + ); expect(fetchOidcConfig).not.toBeCalled(); }); @@ -64,12 +65,14 @@ describe('fetchConnectorProviderDetails', () => { }; fetchOidcConfig.mockRejectedValueOnce(new Error('mock-error')); - const result = await fetchConnectorProviderDetails(connector); + const result = await fetchConnectorProviderDetails(connector, mockTenantId); - expect(result).toEqual({ - ...connector, - providerLogo: ssoConnectorFactories[connector.providerName].logo, - }); + expect(result).toMatchObject( + expect.objectContaining({ + ...connector, + providerLogo: ssoConnectorFactories[connector.providerName].logo, + }) + ); expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer); }); @@ -81,16 +84,18 @@ describe('fetchConnectorProviderDetails', () => { }; fetchOidcConfig.mockResolvedValueOnce({ tokenEndpoint: 'http://example.com/token' }); - const result = await fetchConnectorProviderDetails(connector); + const result = await fetchConnectorProviderDetails(connector, mockTenantId); - expect(result).toEqual({ - ...connector, - providerLogo: ssoConnectorFactories[connector.providerName].logo, - providerConfig: { - ...connector.config, - scope: 'openid', // Default scope - tokenEndpoint: 'http://example.com/token', - }, - }); + expect(result).toMatchObject( + expect.objectContaining({ + ...connector, + providerLogo: ssoConnectorFactories[connector.providerName].logo, + providerConfig: { + ...connector.config, + scope: 'openid', // Default scope + tokenEndpoint: 'http://example.com/token', + }, + }) + ); }); }); diff --git a/packages/core/src/routes/sso-connector/utils.ts b/packages/core/src/routes/sso-connector/utils.ts index 44b953c36..543ca82d8 100644 --- a/packages/core/src/routes/sso-connector/utils.ts +++ b/packages/core/src/routes/sso-connector/utils.ts @@ -4,7 +4,7 @@ import { conditional, trySafe } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; import { type SingleSignOnFactory, ssoConnectorFactories } from '#src/sso/index.js'; -import { type SsoProviderName, type SupportedSsoConnector } from '#src/sso/types/index.js'; +import { type SupportedSsoConnector, type SsoProviderName } from '#src/sso/types/index.js'; import { type SsoConnectorWithProviderConfig } from './type.js'; @@ -57,14 +57,15 @@ export const parseConnectorConfig = ( Return undefined if failed to fetch or parse the config. */ export const fetchConnectorProviderDetails = async ( - connector: SupportedSsoConnector + connector: SupportedSsoConnector, + tenantId: string ): Promise => { const { providerName } = connector; const { logo, constructor } = ssoConnectorFactories[providerName]; const providerConfig = await trySafe(async () => { - const instance = new constructor(connector); + const instance = new constructor(connector, tenantId); return instance.getConfig(); }); diff --git a/packages/core/src/sso/SamlConnector/utils.ts b/packages/core/src/sso/SamlConnector/utils.ts index 17c52aaaf..1527edf0d 100644 --- a/packages/core/src/sso/SamlConnector/utils.ts +++ b/packages/core/src/sso/SamlConnector/utils.ts @@ -1,4 +1,4 @@ -import * as validator from '@authenio/samlify-xsd-schema-validator'; +import * as validator from '@authenio/samlify-node-xmllint'; import { ConnectorError, ConnectorErrorCodes, socialUserInfoGuard } from '@logto/connector-kit'; import { type Optional, conditional } from '@silverhand/essentials'; import { got } from 'got'; diff --git a/packages/core/src/sso/SamlSsoConnector/index.test.ts b/packages/core/src/sso/SamlSsoConnector/index.test.ts index 33e59c73c..18ee8a2c2 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.test.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.test.ts @@ -14,15 +14,24 @@ describe('SamlSsoConnector', () => { expect(samlSsoConnectorFactory.configGuard).toBeDefined(); }); + it('constructor should work properly', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const createSamlSsoConnector = () => + new samlSsoConnectorFactory.constructor(mockSsoConnector, 'default_tenant'); + + expect(createSamlSsoConnector).not.toThrow(); + }); + it('constructor should throw error if config is invalid', () => { - const result = samlSsoConnectorFactory.configGuard.safeParse(mockSsoConnector.config); + const temporaryMockSsoConnector = { ...mockSsoConnector, config: { metadata: 123 } }; + const result = samlSsoConnectorFactory.configGuard.safeParse(temporaryMockSsoConnector.config); if (result.success) { throw new Error('Invalid config'); } const createSamlSsoConnector = () => { - return new samlSsoConnectorFactory.constructor(mockSsoConnector, 'http://localhost:3001/api'); + return new samlSsoConnectorFactory.constructor(temporaryMockSsoConnector, 'default_tenant'); }; expect(createSamlSsoConnector).toThrow( diff --git a/packages/core/src/sso/SamlSsoConnector/index.ts b/packages/core/src/sso/SamlSsoConnector/index.ts index 2f1cf868f..eedafeb8e 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -14,7 +14,6 @@ import { samlConnectorConfigGuard } from '../types/saml.js'; * @property data The SAML connector data from the database * * @method getConfig Get parsed SAML config along with it's metadata. Throws error if config is invalid. - * @method getAuthorizationUrl Get SAML auth URL. * @method getUserInfo Get social user info. */ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { @@ -50,17 +49,6 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { async getUserInfo(assertion: Record) { return this.parseSamlAssertion(assertion); } - - /** - * Get SAML auth URL. - * - * @param jti The current session id. - * - * @returns The SAML auth URL. - */ - async getAuthorizationUrl(jti: string) { - return this.getSingleSignOnUrl(jti); - } } export const samlSsoConnectorFactory: SingleSignOnFactory = { diff --git a/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts new file mode 100644 index 000000000..4bdf91036 --- /dev/null +++ b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts @@ -0,0 +1,29 @@ +import { type JsonObject } from '@logto/schemas'; + +import { logtoUrl } from '#src/constants.js'; + +const logtoIssuer = `${logtoUrl}/oidc`; + +const metadataXml = `k0omv1kPQA+EPB8Q4VQ0JhEsnsAUuQIGcmHIINO0HEQ=MDx2LNmZV1mrc8fCH43Gmz403A3ix8m5ahcj2wQJub2pvS4JZ8F4J2ZBQS5x3W+H+oxjbpeXBExjmNRFQgI2Y1wWuNZcGlv5v0Rzv1s4Nmc72w24k11GtHq+cU1YgSt23z112UWDsq/WPvPjRd1oXGLM/S56nfyaeR1ig1WoqVEs+T+8MEAbCEpidL9CK5oEmF4IqAy1VouRfHEwWF/BGXsdaDxad5cgLnvmPSdVojQYRQCQasy0o/JIeXQzsSLm4V9+U3InR4GqvuJDpdnJJ/tC7flYSqWaDcYmhV0UkkS7KUkf7C0CS08Mz8Jx6ukLpJ3BfSaqGCtxmmu1IJdg8g==MIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4NMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4NNameThe mutable display name of the user.SubjectAn immutable, globally unique, non-reusable identifier of the user that is unique to the application for which a token is issued.Given NameFirst name of the user.SurnameLast name of the user.Display NameDisplay name of the user.Nick NameNick name of the user.Authentication InstantThe time (UTC) when the user is authenticated to Windows Azure Active Directory.Authentication MethodThe method that Windows Azure Active Directory uses to authenticate users.ObjectIdentifierPrimary identifier for the user in the directory. Immutable, globally unique, non-reusable.TenantIdIdentifier for the user's tenant.IdentityProviderIdentity provider for the user.EmailEmail address of the user.GroupsGroups of the user.External Access TokenAccess token issued by external identity provider.External Access Token ExpirationUTC expiration time of access token issued by external identity provider.External OpenID 2.0 IdentifierOpenID 2.0 identifier issued by external identity provider.GroupsOverageClaimIssued when number of user's group claims exceeds return limit.Role ClaimRoles that the user or Service Principal is attached toRoleTemplate Id ClaimRole template id of the Built-in Directory Roles that the user is a member ofhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4Nhttps://sts.windows.net/ac016212-4f8d-46c6-892c-57c90a255a02/https://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4N`; + +export const providerNames = ['OIDC', 'SAML']; +export const partialConfigAndProviderNames: Array<{ + providerName: string; + config: JsonObject; +}> = [ + { + providerName: 'OIDC', + config: { + clientId: 'foo', + clientSecret: 'foo', + issuer: logtoIssuer, + scope: 'openid', + }, + }, + { + providerName: 'SAML', + config: { + metadata: metadataXml, + }, + }, +]; diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index 56fee3291..58e015eb9 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -4,6 +4,7 @@ import { logtoConsoleUrl, logtoUrl, logtoCloudUrl } from '#src/constants.js'; const api = got.extend({ prefixUrl: new URL('/api', logtoUrl), + timeout: { response: 5000 }, // The default is 60s which is way too long for tests. }); export default api; diff --git a/packages/integration-tests/src/tests/api/sso-connectors.test.ts b/packages/integration-tests/src/tests/api/sso-connectors.test.ts index b1a6ecfb8..d7ea3848d 100644 --- a/packages/integration-tests/src/tests/api/sso-connectors.test.ts +++ b/packages/integration-tests/src/tests/api/sso-connectors.test.ts @@ -1,5 +1,9 @@ import { HTTPError } from 'got'; +import { + providerNames, + partialConfigAndProviderNames, +} from '#src/__mocks__/sso-connectors-mock.js'; import { getSsoConnectorFactories, createSsoConnector, @@ -9,9 +13,6 @@ import { patchSsoConnectorById, patchSsoConnectorConfigById, } from '#src/api/sso-connector.js'; -import { logtoUrl } from '#src/constants.js'; - -const logtoIssuer = `${logtoUrl}/oidc`; describe('sso-connector library', () => { it('should return sso-connector-factories', async () => { @@ -20,7 +21,13 @@ describe('sso-connector library', () => { expect(response).toHaveProperty('standardConnectors'); expect(response).toHaveProperty('providerConnectors'); - expect(response.standardConnectors.length).toBeGreaterThan(0); + expect(response.standardConnectors.length).toBe(2); + expect( + response.standardConnectors.find(({ providerName }) => providerName === 'OIDC') + ).toBeDefined(); + expect( + response.standardConnectors.find(({ providerName }) => providerName === 'SAML') + ).toBeDefined(); }); }); @@ -50,14 +57,14 @@ describe('post sso-connectors', () => { ).rejects.toThrow(HTTPError); }); - it('should create a new sso connector', async () => { + it.each(providerNames)('should create a new sso connector', async (providerName) => { const response = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'test', }); expect(response).toHaveProperty('id'); - expect(response).toHaveProperty('providerName', 'OIDC'); + expect(response).toHaveProperty('providerName', providerName); expect(response).toHaveProperty('connectorName', 'test'); expect(response).toHaveProperty('config', {}); expect(response).toHaveProperty('domains', []); @@ -67,48 +74,49 @@ describe('post sso-connectors', () => { await deleteSsoConnectorById(response.id); }); - it('should throw if invalid config is provided', async () => { + it.each(providerNames)('should throw if invalid config is provided', async (providerName) => { await expect( createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'test', config: { issuer: 23, + entityId: 123, }, }) ).rejects.toThrow(HTTPError); }); - it('should create a new sso connector with partial configs', async () => { - const data = { - providerName: 'OIDC', - connectorName: 'test', - config: { - clientId: 'foo', - issuer: 'https://test.com', - }, - domains: ['test.com'], - ssoOnly: true, - }; + it.each(partialConfigAndProviderNames)( + 'should create a new sso connector with partial configs', + async ({ providerName, config }) => { + const data = { + providerName, + connectorName: 'test', + config, + domains: ['test.com'], + ssoOnly: true, + }; - const response = await createSsoConnector(data); + const response = await createSsoConnector(data); - expect(response).toHaveProperty('id'); - expect(response).toHaveProperty('providerName', 'OIDC'); - expect(response).toHaveProperty('connectorName', 'test'); - expect(response).toHaveProperty('config', data.config); - expect(response).toHaveProperty('domains', data.domains); - expect(response).toHaveProperty('ssoOnly', data.ssoOnly); - expect(response).toHaveProperty('syncProfile', false); + expect(response).toHaveProperty('id'); + expect(response).toHaveProperty('providerName', providerName); + expect(response).toHaveProperty('connectorName', 'test'); + expect(response).toHaveProperty('config', data.config); + expect(response).toHaveProperty('domains', data.domains); + expect(response).toHaveProperty('ssoOnly', data.ssoOnly); + expect(response).toHaveProperty('syncProfile', false); - await deleteSsoConnectorById(response.id); - }); + await deleteSsoConnectorById(response.id); + } + ); }); describe('get sso-connectors', () => { - it('should return sso connectors', async () => { + it.each(providerNames)('should return sso connectors', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'test', }); @@ -120,8 +128,11 @@ describe('get sso-connectors', () => { expect(connector).toBeDefined(); expect(connector?.providerLogo).toBeDefined(); - // Invalid config - expect(connector?.providerConfig).toBeUndefined(); + // Empty config object is a valid SAML config. + if (providerName === 'OIDC') { + // Invalid config + expect(connector?.providerConfig).toBeUndefined(); + } await deleteSsoConnectorById(id); }); @@ -132,16 +143,16 @@ describe('get sso-connector by id', () => { await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError); }); - it('should return sso connector', async () => { + it.each(providerNames)('should return sso connector', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); const connector = await getSsoConnectorById(id); expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); + expect(connector).toHaveProperty('providerName', providerName); expect(connector).toHaveProperty('connectorName', 'integration_test connector'); expect(connector).toHaveProperty('config', {}); expect(connector).toHaveProperty('domains', []); @@ -157,9 +168,9 @@ describe('delete sso-connector by id', () => { await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError); }); - it('should delete sso connector', async () => { + it.each(providerNames)('should delete sso connector', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); @@ -178,9 +189,9 @@ describe('patch sso-connector by id', () => { ); }); - it('should patch sso connector without config', async () => { + it.each(providerNames)('should patch sso connector without config', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); @@ -191,7 +202,7 @@ describe('patch sso-connector by id', () => { }); expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); + expect(connector).toHaveProperty('providerName', providerName); expect(connector).toHaveProperty('connectorName', 'integration_test connector updated'); expect(connector).toHaveProperty('config', {}); expect(connector).toHaveProperty('domains', ['test.com']); @@ -201,9 +212,9 @@ describe('patch sso-connector by id', () => { await deleteSsoConnectorById(id); }); - it('should directly return if no changes are made', async () => { + it.each(providerNames)('should directly return if no changes are made', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); @@ -212,7 +223,7 @@ describe('patch sso-connector by id', () => { }); expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); + expect(connector).toHaveProperty('providerName', providerName); expect(connector).toHaveProperty('connectorName', 'integration_test connector'); expect(connector).toHaveProperty('config', {}); expect(connector).toHaveProperty('domains', []); @@ -222,17 +233,17 @@ describe('patch sso-connector by id', () => { await deleteSsoConnectorById(id); }); - it('should throw if invalid config is provided', async () => { + it.each(providerNames)('should throw if invalid config is provided', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); await expect( patchSsoConnectorById(id, { config: { - clientId: 'foo', - issuer: logtoIssuer, + issuer: 23, + entityId: 123, }, }) ).rejects.toThrow(HTTPError); @@ -240,36 +251,29 @@ describe('patch sso-connector by id', () => { await deleteSsoConnectorById(id); }); - it('should patch sso connector with config', async () => { - const { id } = await createSsoConnector({ - providerName: 'OIDC', - connectorName: 'integration_test connector', - }); + it.each(partialConfigAndProviderNames)( + 'should patch sso connector with config', + async ({ providerName, config }) => { + const { id } = await createSsoConnector({ + providerName, + connectorName: 'integration_test connector', + }); - const connector = await patchSsoConnectorById(id, { - connectorName: 'integration_test connector updated', - config: { - clientId: 'foo', - clientSecret: 'bar', - issuer: logtoIssuer, - scope: 'profile email', - }, - syncProfile: true, - }); + const connector = await patchSsoConnectorById(id, { + connectorName: 'integration_test connector updated', + config, + syncProfile: true, + }); - expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); - expect(connector).toHaveProperty('connectorName', 'integration_test connector updated'); - expect(connector).toHaveProperty('config', { - clientId: 'foo', - clientSecret: 'bar', - issuer: logtoIssuer, - scope: 'profile email openid', // Should merged with default scope openid - }); - expect(connector).toHaveProperty('syncProfile', true); + expect(connector).toHaveProperty('id', id); + expect(connector).toHaveProperty('providerName', providerName); + expect(connector).toHaveProperty('connectorName', 'integration_test connector updated'); + expect(connector).toHaveProperty('config', config); + expect(connector).toHaveProperty('syncProfile', true); - await deleteSsoConnectorById(id); - }); + await deleteSsoConnectorById(id); + } + ); }); describe('patch sso-connector config by id', () => { @@ -277,48 +281,42 @@ describe('patch sso-connector config by id', () => { await expect(patchSsoConnectorConfigById('invalid-id', {})).rejects.toThrow(HTTPError); }); - it('should throw if invalid config is provided', async () => { + it.each(providerNames)('should throw if invalid config is provided', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', config: { clientSecret: 'bar', + metadataType: 'URL', }, }); await expect( patchSsoConnectorConfigById(id, { - clientId: 'foo', + issuer: 23, + entityId: 123, }) ).rejects.toThrow(HTTPError); await deleteSsoConnectorById(id); }); - it('should patch sso connector config', async () => { - const { id } = await createSsoConnector({ - providerName: 'OIDC', - connectorName: 'integration_test connector', - config: { - clientId: 'foo', - }, - }); + it.each(partialConfigAndProviderNames)( + 'should patch sso connector config', + async ({ providerName, config }) => { + const { id } = await createSsoConnector({ + providerName, + connectorName: 'integration_test connector', + }); - const connector = await patchSsoConnectorConfigById(id, { - clientSecret: 'bar', - issuer: logtoIssuer, - }); + const connector = await patchSsoConnectorConfigById(id, config); - expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); - expect(connector).toHaveProperty('connectorName', 'integration_test connector'); - expect(connector).toHaveProperty('config', { - clientId: 'foo', - clientSecret: 'bar', - issuer: logtoIssuer, - scope: 'openid', - }); + expect(connector).toHaveProperty('id', id); + expect(connector).toHaveProperty('providerName', providerName); + expect(connector).toHaveProperty('connectorName', 'integration_test connector'); + expect(connector).toHaveProperty('config', config); - await deleteSsoConnectorById(id); - }); + await deleteSsoConnectorById(id); + } + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37da2546d..9e1f9782d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3121,9 +3121,9 @@ importers: packages/core: dependencies: - '@authenio/samlify-xsd-schema-validator': - specifier: ^1.0.5 - version: 1.0.5(samlify@2.8.10) + '@authenio/samlify-node-xmllint': + specifier: ^2.0.0 + version: 2.0.0(samlify@2.8.10) '@aws-sdk/client-s3': specifier: ^3.315.0 version: 3.315.0 @@ -4171,12 +4171,12 @@ packages: '@jridgewell/trace-mapping': 0.3.18 dev: true - /@authenio/samlify-xsd-schema-validator@1.0.5(samlify@2.8.10): - resolution: {integrity: sha512-HJjmjM1WbeB/z4nVbYEcmtIWTLPKqjrqRGEpC9lu7s03Usc4nxxfrJGjHgh3M8MvBJy4neVUoeM9rP4ym3GLgg==} + /@authenio/samlify-node-xmllint@2.0.0(samlify@2.8.10): + resolution: {integrity: sha512-V9cQ0CHqu3JwOmbSecGPUnzIES5kHxD00FEZKnWh90ksQUJG5/TscV2r9XLbKp7MlRMOSUfWxecM35xPSLFdSg==} peerDependencies: samlify: '>= 2.6.0' dependencies: - '@authenio/xsd-schema-validator': 0.7.3 + node-xmllint: 1.0.0 samlify: 2.8.10 dev: false @@ -4189,11 +4189,6 @@ packages: xpath: 0.0.32 dev: false - /@authenio/xsd-schema-validator@0.7.3: - resolution: {integrity: sha512-Jhc/Hxv90bacZr0Fv+u+PEb440zPh4mO6rw+bzEAIBiFLKCtRa/BvKGRxPdCAwsGRPuwl2hFqQGF+Lfz6Q8kFg==} - requiresBuild: true - dev: false - /@aws-crypto/crc32@3.0.0: resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} dependencies: @@ -16768,6 +16763,10 @@ packages: asn1: 0.2.6 dev: false + /node-xmllint@1.0.0: + resolution: {integrity: sha512-71UV2HRUP+djvHpdyatiuv+Y1o8hI4ZI7bMfuuoACMLR1JJCErM4WXAclNeHd6BgHXkqeqnnAk3wpDkSQWmFXw==} + dev: false + /nodemailer@6.9.1: resolution: {integrity: sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==} engines: {node: '>=6.0.0'}