diff --git a/packages/core/package.json b/packages/core/package.json index 7ac193a4b..2af1ed7f6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,7 @@ "deepmerge": "^4.2.2", "dotenv": "^16.0.0", "etag": "^1.8.1", + "fast-xml-parser": "^4.2.5", "find-up": "^6.3.0", "got": "^13.0.0", "hash-wasm": "^4.9.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/__mocks__/sso.ts b/packages/core/src/__mocks__/sso.ts index f70bf7477..4431c0c50 100644 --- a/packages/core/src/__mocks__/sso.ts +++ b/packages/core/src/__mocks__/sso.ts @@ -1,6 +1,9 @@ import { type SsoConnector } from '@logto/schemas'; import { SsoProviderName } from '#src/sso/types/index.js'; +import { type BaseOidcConfig } from '#src/sso/types/oidc.js'; +import { MetadataType } from '#src/sso/types/saml.js'; +import { type BaseSamlConfig } from '#src/sso/types/saml.js'; export const mockSsoConnector = { id: 'mock-sso-connector', @@ -31,3 +34,25 @@ export const wellConfiguredSsoConnector = { ssoOnly: true, createdAt: Date.now(), } satisfies SsoConnector; + +export const mockBaseSamlConfig = { + metadataType: MetadataType.XML, + metadataXml: 'metadataXml', + attributeMapping: {}, + entityId: 'entityId', + nameIdFormat: ['nameIdFormat'], + signInEndpoint: 'signInEndpoint', + signingAlgorithm: 'signingAlgorithm', + x509Certificate: 'x509Certificate', +} satisfies BaseSamlConfig; + +export const mockBaseOidcConfig = { + authorizationEndpoint: 'authorizationEndpoint', + tokenEndpoint: 'tokenEndpoint', + userinfoEndpoint: 'userinfoEndpoint', + jwksUri: 'jwksUri', + issuer: 'issuer', + clientId: 'clientId', + clientSecret: 'clientSecret', + scope: 'openid profile', +} satisfies BaseOidcConfig; diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index 6699e31a7..a93fe50de 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -6,6 +6,8 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { ssoConnectorFactories } from '#src/sso/index.js'; +import { SsoProviderName } from '#src/sso/types/index.js'; import assertThat from '#src/utils/assert-that.js'; import { getConnectorSessionResultFromJti, @@ -19,11 +21,12 @@ import type { AnonymousRouter, RouterInitArgs } from './types.js'; * This router will have a route `/authn` to authenticate tokens with a general manner. */ export default function authnRoutes( - ...[router, { envSet, provider, libraries }]: RouterInitArgs + ...[router, { id: tenantId, envSet, provider, libraries }]: RouterInitArgs ) { const { users: { findUserRoles }, socials: { getConnector }, + ssoConnector: { getSsoConnectorById }, } = libraries; const hasuraResponseGuard = z.object({ @@ -144,4 +147,57 @@ export default function authnRoutes( return next(); } ); + + // TODO: refactor this, this SAML API for SSO is quite similar to the one for normal social sign-in, most of the logics can be reused. + router.post( + '/authn/saml/sso/:ssoConnectorId', + /** + * The API does not care the type of the SAML assertion request body, simply pass this to + * SSO connector's built-in methods. + */ + koaGuard({ + body: jsonObjectGuard, + params: z.object({ ssoConnectorId: z.string().min(1) }), + status: 302, + }), + async (ctx, next) => { + const { + params: { ssoConnectorId }, + body, + } = ctx.guard; + const ssoConnector = await getSsoConnectorById(ssoConnectorId); + + const samlAssertionGuard = z.object({ SAMLResponse: z.string(), RelayState: z.string() }); + const samlAssertionParseResult = samlAssertionGuard.safeParse(body); + + if (!samlAssertionParseResult.success) { + throw new ConnectorError( + ConnectorErrorCodes.InvalidResponse, + samlAssertionParseResult.error + ); + } + + /** + * Since `RelayState` will be returned with value unchanged, we use it to pass `jti` + * to find the connector session we used to store essential information. + */ + const { RelayState: jti } = samlAssertionParseResult.data; + + const getSession = async () => getConnectorSessionResultFromJti(jti, provider); + const setSession = async (connectorSession: ConnectorSession) => + assignConnectorSessionResultViaJti(jti, provider, connectorSession); + + if (ssoConnector.providerName !== SsoProviderName.SAML) { + throw new RequestError({ code: 'sso_connector.saml_only' }); + } + + const { constructor } = ssoConnectorFactories[ssoConnector.providerName]; + const { validateSamlAssertion } = new constructor(ssoConnector, tenantId); + const redirectTo = await validateSamlAssertion({ body }, getSession, setSession); + + ctx.redirect(redirectTo); + + return next(); + } + ); } diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index 35a370410..2d9fc74c2 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -22,6 +22,7 @@ export default function singleSignOnRoutes( tenant: TenantContext ) { const { + id: tenantId, provider, libraries: { ssoConnector }, } = tenant; @@ -43,7 +44,8 @@ export default function singleSignOnRoutes( }), }), async (ctx, next) => { - const { interactionDetails, guard, createLog } = ctx; + const { interactionDetails, guard, createLog, req, res } = ctx; + const { jti } = await provider.interactionDetails(req, res); // Check interaction exists const { event } = getInteractionStorage(interactionDetails.result); @@ -75,12 +77,13 @@ export default function singleSignOnRoutes( try { // Will throw ConnectorError if the config is invalid const connectorInstance = new ssoConnectorFactories[connectorData.providerName].constructor( - connectorData + connectorData, + tenantId ); // Will throw ConnectorError if failed to fetch the provider's config const redirectTo = await connectorInstance.getAuthorizationUrl( - { state, redirectUri }, + { state, redirectUri, jti }, async (connectorSession: ConnectorSession) => assignConnectorSessionResult(ctx, provider, connectorSession) ); diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index fb294521d..c1e54c835 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -5,19 +5,20 @@ 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 { consoleLog } from '#src/utils/console.js'; + +import { type AuthedRouter, type RouterInitArgs } from '../types.js'; + import { parseFactoryDetail, parseConnectorConfig, @@ -28,13 +29,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 @@ -121,10 +124,11 @@ export default function singleSignOnRoutes(...args: Rout }), async (ctx, next) => { const connectors = await getSsoConnectors(); + consoleLog.info('connectors:', connectors); // 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 +151,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 +212,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 +252,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 da657d21a..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 as SsoProviderName].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 as SsoProviderName].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 as SsoProviderName].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..1699153da 100644 --- a/packages/core/src/routes/sso-connector/utils.ts +++ b/packages/core/src/routes/sso-connector/utils.ts @@ -2,15 +2,36 @@ import { type I18nPhrases } from '@logto/connector-kit'; import { type JsonObject } from '@logto/schemas'; import { conditional, trySafe } from '@silverhand/essentials'; +import { mockBaseSamlConfig, mockBaseOidcConfig } from '#src/__mocks__/sso.js'; 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 } from '#src/sso/types/index.js'; +import { SsoProviderName } from '#src/sso/types/index.js'; +import { basicSamlConnectorConfigPartialGuard } from '#src/sso/types/saml.js'; import { type SsoConnectorWithProviderConfig } from './type.js'; +const { + EnvSet: { + values: { isIntegrationTest }, + }, +} = await import('#src/env-set/index.js'); + const isKeyOfI18nPhrases = (key: string, phrases: I18nPhrases): key is keyof I18nPhrases => key in phrases; +const getPartialConfigGuard = (providerName: SsoProviderName, allowPartial?: boolean) => { + if (!allowPartial) { + return ssoConnectorFactories[providerName].configGuard; + } + + if (providerName === SsoProviderName.SAML) { + return basicSamlConnectorConfigPartialGuard; + } + + return ssoConnectorFactories[providerName].configGuard.partial(); +}; + export const parseFactoryDetail = ( factory: SingleSignOnFactory, locale: string @@ -35,11 +56,8 @@ export const parseConnectorConfig = ( config: JsonObject, allowPartial?: boolean ) => { - const factory = ssoConnectorFactories[providerName]; - - const result = allowPartial - ? factory.configGuard.partial().safeParse(config) - : factory.configGuard.safeParse(config); + const configGuard = getPartialConfigGuard(providerName, allowPartial); + const result = configGuard.safeParse(config); if (!result.success) { throw new RequestError({ @@ -57,14 +75,19 @@ 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); + // To avoid `getConfig()` being called in integration tests and throwing time out error. + if (isIntegrationTest) { + return providerName === SsoProviderName.OIDC ? mockBaseOidcConfig : mockBaseSamlConfig; + } return instance.getConfig(); }); diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts new file mode 100644 index 000000000..765beeb07 --- /dev/null +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -0,0 +1,174 @@ +import { + ConnectorError, + ConnectorErrorCodes, + type GetSession, + type SetSession, +} from '@logto/connector-kit'; +import { assert, appendPath } from '@silverhand/essentials'; +import * as saml from 'samlify'; +import { z } from 'zod'; + +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; + +import { + type BaseSamlConfig, + type BaseSamlConnectorConfig, + attributeMappingPostProcessor, +} from '../types/saml.js'; + +import { + fetchSamlConfig, + getRawSamlConfig, + getUserInfoFromRawUserProfile, + samlAssertionHandler, +} from './utils.js'; + +class SamlConnector { + private readonly _acsUrl: string; + constructor( + private readonly config: BaseSamlConnectorConfig, + tenantId: string, + ssoConnectorId: string + ) { + this._acsUrl = appendPath( + getTenantEndpoint(tenantId, EnvSet.values), + `api/authn/saml/sso/${ssoConnectorId}` + ).toString(); + } + + get acsUrl() { + return this._acsUrl; + } + + /* Fetch SAML config from the metadata XML file or metadata URL. Throws error if config is invalid. */ + getSamlConfig = async (): Promise => { + const samlConfig = await fetchSamlConfig(this.config); + + return { + ...samlConfig, + ...this.config, + }; + }; + + getIdpMetadata = async () => { + return getRawSamlConfig(this.config); + }; + + getAuthorizationUrl = async ( + { + state, + redirectUri, + jti, + }: { + state: string; + redirectUri: string; + jti: string; + }, + setSession: SetSession + ) => { + const { + entityId: entityID, + x509Certificate, + nameIdFormat, + signingAlgorithm, + } = await this.getSamlConfig(); + + assert( + setSession, + new ConnectorError(ConnectorErrorCodes.NotImplemented, { + message: 'Function `setSession()` is not implemented.', + }) + ); + + const storage = { state, redirectUri, jti }; + await setSession(storage); + + try { + const idpMetadataXml = await getRawSamlConfig(this.config); + // 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); + } + }; + + getUserInfo = async (_data: unknown, getSession: GetSession) => { + const parsedConfig = await this.getSamlConfig(); + const { attributeMapping } = parsedConfig; + const profileMap = attributeMappingPostProcessor(attributeMapping); + + assert( + getSession, + new ConnectorError(ConnectorErrorCodes.NotImplemented, { + message: 'Function `getSession()` is not implemented.', + }) + ); + const { extractedRawProfile } = await getSession(); + + const extractedRawProfileGuard = z.record(z.string().or(z.array(z.string()))); + const rawProfileParseResult = extractedRawProfileGuard.safeParse(extractedRawProfile); + + if (!rawProfileParseResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error); + } + + const rawUserProfile = rawProfileParseResult.data; + + return getUserInfoFromRawUserProfile(rawUserProfile, profileMap); + }; + + validateSamlAssertion = async ( + assertion: Record, + getSession: GetSession, + setSession: SetSession + ): Promise => { + const parsedConfig = await this.getSamlConfig(); + const idpMetadataXml = await this.getIdpMetadata(); + + const connectorSession = await getSession(); + const { redirectUri, state } = connectorSession; + + await samlAssertionHandler(assertion, { ...parsedConfig, idpMetadataXml }, setSession); + + assert( + state, + new ConnectorError(ConnectorErrorCodes.General, { + message: 'Can not find `state` from connector session.', + }) + ); + assert( + redirectUri, + new ConnectorError(ConnectorErrorCodes.General, { + message: 'Can not find `redirectUri` from connector session.', + }) + ); + + const queryParameters = new URLSearchParams({ state }); + + return `${redirectUri}?${queryParameters.toString()}`; + }; +} + +export default SamlConnector; diff --git a/packages/core/src/sso/SamlConnector/utils.ts b/packages/core/src/sso/SamlConnector/utils.ts new file mode 100644 index 000000000..3d102a542 --- /dev/null +++ b/packages/core/src/sso/SamlConnector/utils.ts @@ -0,0 +1,168 @@ +import { + ConnectorError, + ConnectorErrorCodes, + socialUserInfoGuard, + type SetSession, +} from '@logto/connector-kit'; +import { XMLValidator } from 'fast-xml-parser'; +import { got } from 'got'; +import * as saml from 'samlify'; +import { z } from 'zod'; + +import { + samlMetadataGuard, + type SamlMetadata, + type BaseSamlConnectorConfig, + type ProfileMap, + MetadataType, + type BaseSamlConfig, +} from '../types/saml.js'; + +type ESamlHttpRequest = Parameters[2]; + +const xmlValidator = (xml: string) => { + try { + XMLValidator.validate(xml, { + allowBooleanAttributes: true, + }); + } catch (error: unknown) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, error); + } +}; + +const parseXmlMetadata = (xml: string): SamlMetadata => { + xmlValidator(xml); + + // eslint-disable-next-line new-cap + const idP = saml.IdentityProvider({ metadata: xml }); + + 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, value]) => 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; +}; + +export const getRawSamlConfig = async (config: BaseSamlConnectorConfig): Promise => { + if (config.metadataType === MetadataType.URL) { + const { body } = await got.get(config.metadataUrl); + + const result = z.string().safeParse(body); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); + } + + return result.data; + } + + return config.metadataXml; +}; + +export const fetchSamlConfig = async (config: BaseSamlConnectorConfig) => { + const rawMetadata = await getRawSamlConfig(config); + return parseXmlMetadata(rawMetadata); +}; + +export const getUserInfoFromRawUserProfile = ( + rawUserProfile: Record, + keyMapping: ProfileMap +) => { + const keyMap = new Map( + Object.entries(keyMapping).map(([destination, source]) => [source, destination]) + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const mappedUserProfile = Object.fromEntries( + Object.entries(rawUserProfile) + .filter(([key, value]) => keyMap.get(key) && value) + .map(([key, value]) => [keyMap.get(key), value]) + ); + + const result = socialUserInfoGuard.safeParse(mappedUserProfile); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + } + + return result.data; +}; + +export const samlAssertionHandler = async ( + request: ESamlHttpRequest, + options: BaseSamlConfig & { idpMetadataXml: string }, + setSession: SetSession +): Promise> => { + const { entityId: entityID, x509Certificate, idpMetadataXml } = options; + // 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({ + validate: async (xmlContent: string) => { + try { + XMLValidator.validate(xmlContent, { + allowBooleanAttributes: true, + }); + + return true; + } catch { + return false; + } + }, + }); + + try { + const assertionResult = await serviceProvider.parseLoginResponse( + identityProvider, + 'post', + request + ); + + await setSession({ + extractedRawProfile: { + ...(Boolean(assertionResult.extract.nameID) && { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + id: assertionResult.extract.nameID, + }), + ...assertionResult.extract.attributes, + }, + }); + } catch (error: unknown) { + throw new ConnectorError(ConnectorErrorCodes.General, String(error)); + } +}; 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..774602da6 --- /dev/null +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -0,0 +1,38 @@ +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 { baseSamlConnectorConfigGuard } from '../types/saml.js'; + +export class SamlSsoConnector extends SamlConnector implements SingleSignOn { + constructor( + private readonly _data: SsoConnector, + tenantId: string + ) { + const parseConfigResult = baseSamlConnectorConfigGuard.safeParse(_data.config); + + if (!parseConfigResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error); + } + + super(parseConfigResult.data, tenantId, _data.id); + } + + get data() { + return this._data; + } + + getConfig = async () => this.getSamlConfig(); +} + +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: baseSamlConnectorConfigGuard, + constructor: SamlSsoConnector, +}; diff --git a/packages/core/src/sso/index.ts b/packages/core/src/sso/index.ts index b57ca7264..9c16b18c7 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 baseSamlConnectorConfigGuard } 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 baseSamlConnectorConfigGuard : 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.test.ts b/packages/core/src/sso/types/saml.test.ts new file mode 100644 index 000000000..45539d933 --- /dev/null +++ b/packages/core/src/sso/types/saml.test.ts @@ -0,0 +1,24 @@ +import { attributeMappingPostProcessor } from './saml.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', + }); + }); +}); diff --git a/packages/core/src/sso/types/saml.ts b/packages/core/src/sso/types/saml.ts new file mode 100644 index 000000000..7699ca0b2 --- /dev/null +++ b/packages/core/src/sso/types/saml.ts @@ -0,0 +1,83 @@ +import { socialUserInfoGuard, socialUserInfoKeys } from '@logto/connector-kit'; +import { conditional } from '@silverhand/essentials'; +import cleanDeep from 'clean-deep'; +import { z } from 'zod'; + +export enum MetadataType { + XML = 'XML', + URL = 'URL', +} + +export type ProfileMap = Required>; + +const attributeMapGuard = socialUserInfoGuard.partial(); +type AttributeMap = z.infer; + +/** + * 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 fallback values + */ +export const attributeMappingPostProcessor = (attributeMapping?: AttributeMap): ProfileMap => { + return { + // eslint-disable-next-line no-restricted-syntax + ...(Object.fromEntries(socialUserInfoKeys.map((key) => [key, key])) as ProfileMap), + ...conditional(attributeMapping && cleanDeep(attributeMapping)), + }; +}; + +const basicSamlCommonFields = { + attributeMapping: attributeMapGuard.optional(), + signInEndpoint: z.string().optional(), + entityId: z.string().optional(), + x509Certificate: z.string().optional(), +}; + +export const baseSamlConnectorConfigGuard = z.discriminatedUnion('metadataType', [ + z.object({ + metadataType: z.literal(MetadataType.URL), + metadataUrl: z.string().url(), + ...basicSamlCommonFields, + }), + z.object({ + metadataType: z.literal(MetadataType.XML), + metadataXml: z.string(), + ...basicSamlCommonFields, + }), +]); + +export type BaseSamlConnectorConfig = z.infer; + +/** + * Zod discriminate union does not support its partial util method, we need to manually implement this. + * This is for guarding the config on creating. + */ +export const basicSamlConnectorConfigPartialGuard = z.discriminatedUnion('metadataType', [ + z + .object({ + metadataUrl: z.string().url(), + ...basicSamlCommonFields, + }) + .partial() + .merge(z.object({ metadataType: z.literal(MetadataType.URL) })), + z + .object({ + metadataXml: z.string(), + ...basicSamlCommonFields, + }) + .partial() + .merge(z.object({ metadataType: z.literal(MetadataType.XML) })), +]); + +export const samlMetadataGuard = z.object({ + entityId: z.string(), + nameIdFormat: z.string().array().optional(), + signInEndpoint: z.string(), + signingAlgorithm: z.string(), + x509Certificate: z.string(), +}); + +export type SamlMetadata = z.infer; + +export type BaseSamlConfig = BaseSamlConnectorConfig & SamlMetadata; 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..5983c2a87 100644 --- a/packages/integration-tests/src/tests/api/sso-connectors.test.ts +++ b/packages/integration-tests/src/tests/api/sso-connectors.test.ts @@ -1,3 +1,4 @@ +import { type JsonObject } from '@logto/schemas'; import { HTTPError } from 'got'; import { @@ -9,9 +10,31 @@ import { patchSsoConnectorById, patchSsoConnectorConfigById, } from '#src/api/sso-connector.js'; -import { logtoUrl } from '#src/constants.js'; -const logtoIssuer = `${logtoUrl}/oidc`; +const providerNames = ['OIDC', 'SAML']; +const partialConfigAndProviderNames: Array<{ + providerName: string; + config: JsonObject; +}> = [ + { + providerName: 'OIDC', + config: { + clientId: 'foo', + clientSecret: 'foo', + issuer: 'https://test.com', + scope: 'openid', + }, + }, + { + providerName: 'SAML', + config: { + metadataType: 'URL', + metadataUrl: 'http://test.com', + attributeMapping: {}, + entityId: 'foo', + }, + }, +]; describe('sso-connector library', () => { it('should return sso-connector-factories', async () => { @@ -20,7 +43,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 +79,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 +96,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', }); @@ -132,16 +162,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 +187,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 +208,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 +221,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 +231,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 +242,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 +252,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 +270,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 +300,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/packages/phrases/src/locales/de/errors/index.ts b/packages/phrases/src/locales/de/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/de/errors/index.ts +++ b/packages/phrases/src/locales/de/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/de/errors/sso-connector.ts b/packages/phrases/src/locales/de/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/de/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/en/errors/index.ts b/packages/phrases/src/locales/en/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/en/errors/index.ts +++ b/packages/phrases/src/locales/en/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/en/errors/sso-connector.ts b/packages/phrases/src/locales/en/errors/sso-connector.ts new file mode 100644 index 000000000..6f82279b0 --- /dev/null +++ b/packages/phrases/src/locales/en/errors/sso-connector.ts @@ -0,0 +1,5 @@ +const sso_connector = { + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/es/errors/index.ts b/packages/phrases/src/locales/es/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/es/errors/index.ts +++ b/packages/phrases/src/locales/es/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/es/errors/sso-connector.ts b/packages/phrases/src/locales/es/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/es/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/fr/errors/index.ts b/packages/phrases/src/locales/fr/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/fr/errors/index.ts +++ b/packages/phrases/src/locales/fr/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/fr/errors/sso-connector.ts b/packages/phrases/src/locales/fr/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/fr/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/it/errors/index.ts b/packages/phrases/src/locales/it/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/it/errors/index.ts +++ b/packages/phrases/src/locales/it/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/it/errors/sso-connector.ts b/packages/phrases/src/locales/it/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/it/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/ja/errors/index.ts b/packages/phrases/src/locales/ja/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/ja/errors/index.ts +++ b/packages/phrases/src/locales/ja/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/ja/errors/sso-connector.ts b/packages/phrases/src/locales/ja/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/ja/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/ko/errors/index.ts b/packages/phrases/src/locales/ko/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/ko/errors/index.ts +++ b/packages/phrases/src/locales/ko/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/ko/errors/sso-connector.ts b/packages/phrases/src/locales/ko/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/ko/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/pl-pl/errors/index.ts b/packages/phrases/src/locales/pl-pl/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/pl-pl/errors/index.ts +++ b/packages/phrases/src/locales/pl-pl/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/pl-pl/errors/sso-connector.ts b/packages/phrases/src/locales/pl-pl/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/pl-pl/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/pt-br/errors/index.ts b/packages/phrases/src/locales/pt-br/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/pt-br/errors/index.ts +++ b/packages/phrases/src/locales/pt-br/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/pt-br/errors/sso-connector.ts b/packages/phrases/src/locales/pt-br/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/pt-br/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/pt-pt/errors/index.ts b/packages/phrases/src/locales/pt-pt/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/pt-pt/errors/index.ts +++ b/packages/phrases/src/locales/pt-pt/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/pt-pt/errors/sso-connector.ts b/packages/phrases/src/locales/pt-pt/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/pt-pt/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/ru/errors/index.ts b/packages/phrases/src/locales/ru/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/ru/errors/index.ts +++ b/packages/phrases/src/locales/ru/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/ru/errors/sso-connector.ts b/packages/phrases/src/locales/ru/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/ru/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/tr-tr/errors/index.ts b/packages/phrases/src/locales/tr-tr/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/tr-tr/errors/index.ts +++ b/packages/phrases/src/locales/tr-tr/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/tr-tr/errors/sso-connector.ts b/packages/phrases/src/locales/tr-tr/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/tr-tr/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/zh-cn/errors/index.ts b/packages/phrases/src/locales/zh-cn/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/zh-cn/errors/index.ts +++ b/packages/phrases/src/locales/zh-cn/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/zh-cn/errors/sso-connector.ts b/packages/phrases/src/locales/zh-cn/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/zh-cn/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/zh-hk/errors/index.ts b/packages/phrases/src/locales/zh-hk/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/zh-hk/errors/index.ts +++ b/packages/phrases/src/locales/zh-hk/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/zh-hk/errors/sso-connector.ts b/packages/phrases/src/locales/zh-hk/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/zh-hk/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/phrases/src/locales/zh-tw/errors/index.ts b/packages/phrases/src/locales/zh-tw/errors/index.ts index acf9eda5f..67d11bdf3 100644 --- a/packages/phrases/src/locales/zh-tw/errors/index.ts +++ b/packages/phrases/src/locales/zh-tw/errors/index.ts @@ -16,6 +16,7 @@ import role from './role.js'; import scope from './scope.js'; import session from './session.js'; import sign_in_experiences from './sign-in-experiences.js'; +import sso_connector from './sso-connector.js'; import storage from './storage.js'; import subscription from './subscription.js'; import swagger from './swagger.js'; @@ -46,6 +47,7 @@ const errors = { subscription, application, organization, + sso_connector, }; export default Object.freeze(errors); diff --git a/packages/phrases/src/locales/zh-tw/errors/sso-connector.ts b/packages/phrases/src/locales/zh-tw/errors/sso-connector.ts new file mode 100644 index 000000000..4913c92be --- /dev/null +++ b/packages/phrases/src/locales/zh-tw/errors/sso-connector.ts @@ -0,0 +1,6 @@ +const sso_connector = { + /** UNTRANSLATED */ + saml_only: 'The endpoint only applies to SAML SSO connectors.', +}; + +export default Object.freeze(sso_connector); diff --git a/packages/toolkit/connector-kit/src/types/social.ts b/packages/toolkit/connector-kit/src/types/social.ts index 865eb0318..6c7051c29 100644 --- a/packages/toolkit/connector-kit/src/types/social.ts +++ b/packages/toolkit/connector-kit/src/types/social.ts @@ -32,6 +32,14 @@ export const socialUserInfoGuard = z.object({ export type SocialUserInfo = z.infer; +export const socialUserInfoKeys = Object.freeze([ + 'id', + 'email', + 'phone', + 'name', + 'avatar', +] satisfies Array); + export type GetUserInfo = ( data: unknown, getSession: GetSession diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ff02f55..ad8224524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3208,6 +3208,9 @@ importers: etag: specifier: ^1.8.1 version: 1.8.1 + fast-xml-parser: + specifier: ^4.2.5 + version: 4.2.5 find-up: specifier: ^6.3.0 version: 6.3.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