From 40a5a18d9084da0bdb2469969e260c089ad5fdc1 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 2 Nov 2023 11:17:34 +0800 Subject: [PATCH] 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: