From 7fe41a0037070c3cc4ba0fae7718a233ae66253b Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 7 Nov 2023 21:12:29 +0800 Subject: [PATCH] feat(core,test): update sso connector util functions, APIs and integration tests (#4807) --- packages/core/package.json | 2 +- .../core/src/include.d/xml-validator.d.ts | 3 - .../src/routes/interaction/single-sign-on.ts | 24 ++- .../core/src/routes/sso-connector/index.ts | 28 +-- .../src/routes/sso-connector/utils.test.ts | 53 ++--- .../core/src/routes/sso-connector/utils.ts | 7 +- packages/core/src/sso/SamlConnector/utils.ts | 2 +- .../src/sso/SamlSsoConnector/index.test.ts | 13 +- .../core/src/sso/SamlSsoConnector/index.ts | 12 -- .../src/__mocks__/sso-connectors-mock.ts | 29 +++ packages/integration-tests/src/api/api.ts | 1 + .../src/tests/api/sso-connectors.test.ts | 202 +++++++++--------- pnpm-lock.yaml | 21 +- 13 files changed, 214 insertions(+), 183 deletions(-) delete mode 100644 packages/core/src/include.d/xml-validator.d.ts create mode 100644 packages/integration-tests/src/__mocks__/sso-connectors-mock.ts diff --git a/packages/core/package.json b/packages/core/package.json index 6df01e1de..2cfac5e3a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ "test:report": "codecov -F core" }, "dependencies": { - "@authenio/samlify-xsd-schema-validator": "^1.0.5", + "@authenio/samlify-node-xmllint": "^2.0.0", "@aws-sdk/client-s3": "^3.315.0", "@azure/storage-blob": "^12.13.0", "@google-cloud/storage": "^7.3.0", diff --git a/packages/core/src/include.d/xml-validator.d.ts b/packages/core/src/include.d/xml-validator.d.ts deleted file mode 100644 index b5b414777..000000000 --- a/packages/core/src/include.d/xml-validator.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module '@authenio/samlify-xsd-schema-validator' { - export declare const validate: (xml: string) => Promise; -} diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index 3d610b980..163808543 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; @@ -22,6 +23,7 @@ export default function singleSignOnRoutes( tenant: TenantContext ) { const { + id: tenantId, provider, libraries: { ssoConnector }, } = tenant; @@ -74,20 +76,20 @@ export default function singleSignOnRoutes( try { // Will throw ConnectorError if the config is invalid - const connectorInstance = new ssoConnectorFactories[connectorData.providerName].constructor( - connectorData - ); + const factory = ssoConnectorFactories[connectorData.providerName]; + const connectorInstance = new factory.constructor(connectorData, tenantId); - // Will throw ConnectorError if failed to fetch the provider's config - const redirectTo = await connectorInstance.getAuthorizationUrl( - { state, redirectUri }, - async (connectorSession: ConnectorSession) => - assignConnectorSessionResult(ctx, provider, connectorSession) - ); + if (connectorInstance instanceof OidcSsoConnector) { + const redirectTo = await connectorInstance.getAuthorizationUrl( + { state, redirectUri }, + async (connectorSession: ConnectorSession) => + assignConnectorSessionResult(ctx, provider, connectorSession) + ); - // TODO: Add SAML connector support later + ctx.body = { redirectTo }; + } - ctx.body = { redirectTo }; + // TODO: Add SAML `getSingleSignOnUrl` here } catch (error: unknown) { // Catch ConnectorError and re-throw as 500 RequestError if (error instanceof ConnectorError) { diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index fb294521d..cafbc6db1 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -5,19 +5,19 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; -import { ssoConnectorFactories, standardSsoConnectorProviders } from '#src/sso/index.js'; -import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js'; -import { tableToPathname } from '#src/utils/SchemaRouter.js'; - -import { type AuthedRouter, type RouterInitArgs } from '../types.js'; - import { connectorFactoriesResponseGuard, type ConnectorFactoryDetail, ssoConnectorCreateGuard, ssoConnectorWithProviderConfigGuard, ssoConnectorPatchGuard, -} from './type.js'; +} from '#src/routes/sso-connector/type.js'; +import { ssoConnectorFactories, standardSsoConnectorProviders } from '#src/sso/index.js'; +import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js'; +import { tableToPathname } from '#src/utils/SchemaRouter.js'; + +import { type AuthedRouter, type RouterInitArgs } from '../types.js'; + import { parseFactoryDetail, parseConnectorConfig, @@ -28,13 +28,15 @@ export default function singleSignOnRoutes(...args: Rout const [ router, { - libraries: { ssoConnector: ssoConnectorLibrary }, + id: tenantId, queries: { ssoConnectors }, + libraries: { + ssoConnector: { getSsoConnectorById, getSsoConnectors }, + }, }, ] = args; const pathname = `/${tableToPathname(SsoConnectors.table)}`; - const { getSsoConnectorById, getSsoConnectors } = ssoConnectorLibrary; /* Get all supported single sign on connector factory details @@ -124,7 +126,7 @@ export default function singleSignOnRoutes(...args: Rout // Fetch provider details for each connector const connectorsWithProviderDetails = await Promise.all( - connectors.map(async (connector) => fetchConnectorProviderDetails(connector)) + connectors.map(async (connector) => fetchConnectorProviderDetails(connector, tenantId)) ); ctx.body = connectorsWithProviderDetails; @@ -147,7 +149,7 @@ export default function singleSignOnRoutes(...args: Rout const connector = await getSsoConnectorById(id); // Fetch provider details for the connector - const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector); + const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId); ctx.body = connectorWithProviderDetails; @@ -208,7 +210,7 @@ export default function singleSignOnRoutes(...args: Rout new RequestError({ code: 'connector.not_found', status: 404 }) ); - const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector); + const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId); ctx.body = connectorWithProviderDetails; @@ -248,7 +250,7 @@ export default function singleSignOnRoutes(...args: Rout ); // Fetch provider details for the connector - const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector); + const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId); ctx.body = connectorWithProviderDetails; diff --git a/packages/core/src/routes/sso-connector/utils.test.ts b/packages/core/src/routes/sso-connector/utils.test.ts index 7fb531d8d..ed3ff4f5e 100644 --- a/packages/core/src/routes/sso-connector/utils.test.ts +++ b/packages/core/src/routes/sso-connector/utils.test.ts @@ -14,6 +14,8 @@ await mockEsmWithActual('#src/sso/OidcConnector/utils.js', () => ({ const { ssoConnectorFactories } = await import('#src/sso/index.js'); const { parseFactoryDetail, fetchConnectorProviderDetails } = await import('./utils.js'); +const mockTenantId = 'mock_tenant_id'; + describe('parseFactoryDetail', () => { it.each(Object.values(SsoProviderName))('should return correct detail for %s', (providerName) => { const { logo, description } = ssoConnectorFactories[providerName]; @@ -43,16 +45,15 @@ describe('parseFactoryDetail', () => { describe('fetchConnectorProviderDetails', () => { it('providerConfig should be undefined if connector config is invalid', async () => { - const connector = { - ...mockSsoConnector, - config: { clientId: 'foo' }, - }; - const result = await fetchConnectorProviderDetails(connector); + const connector = { ...mockSsoConnector, config: { clientId: 'foo' } }; + const result = await fetchConnectorProviderDetails(connector, mockTenantId); - expect(result).toEqual({ - ...connector, - providerLogo: ssoConnectorFactories[connector.providerName].logo, - }); + expect(result).toMatchObject( + expect.objectContaining({ + ...connector, + providerLogo: ssoConnectorFactories[connector.providerName].logo, + }) + ); expect(fetchOidcConfig).not.toBeCalled(); }); @@ -64,12 +65,14 @@ describe('fetchConnectorProviderDetails', () => { }; fetchOidcConfig.mockRejectedValueOnce(new Error('mock-error')); - const result = await fetchConnectorProviderDetails(connector); + const result = await fetchConnectorProviderDetails(connector, mockTenantId); - expect(result).toEqual({ - ...connector, - providerLogo: ssoConnectorFactories[connector.providerName].logo, - }); + expect(result).toMatchObject( + expect.objectContaining({ + ...connector, + providerLogo: ssoConnectorFactories[connector.providerName].logo, + }) + ); expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer); }); @@ -81,16 +84,18 @@ describe('fetchConnectorProviderDetails', () => { }; fetchOidcConfig.mockResolvedValueOnce({ tokenEndpoint: 'http://example.com/token' }); - const result = await fetchConnectorProviderDetails(connector); + const result = await fetchConnectorProviderDetails(connector, mockTenantId); - expect(result).toEqual({ - ...connector, - providerLogo: ssoConnectorFactories[connector.providerName].logo, - providerConfig: { - ...connector.config, - scope: 'openid', // Default scope - tokenEndpoint: 'http://example.com/token', - }, - }); + expect(result).toMatchObject( + expect.objectContaining({ + ...connector, + providerLogo: ssoConnectorFactories[connector.providerName].logo, + providerConfig: { + ...connector.config, + scope: 'openid', // Default scope + tokenEndpoint: 'http://example.com/token', + }, + }) + ); }); }); diff --git a/packages/core/src/routes/sso-connector/utils.ts b/packages/core/src/routes/sso-connector/utils.ts index 44b953c36..543ca82d8 100644 --- a/packages/core/src/routes/sso-connector/utils.ts +++ b/packages/core/src/routes/sso-connector/utils.ts @@ -4,7 +4,7 @@ import { conditional, trySafe } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; import { type SingleSignOnFactory, ssoConnectorFactories } from '#src/sso/index.js'; -import { type SsoProviderName, type SupportedSsoConnector } from '#src/sso/types/index.js'; +import { type SupportedSsoConnector, type SsoProviderName } from '#src/sso/types/index.js'; import { type SsoConnectorWithProviderConfig } from './type.js'; @@ -57,14 +57,15 @@ export const parseConnectorConfig = ( Return undefined if failed to fetch or parse the config. */ export const fetchConnectorProviderDetails = async ( - connector: SupportedSsoConnector + connector: SupportedSsoConnector, + tenantId: string ): Promise => { const { providerName } = connector; const { logo, constructor } = ssoConnectorFactories[providerName]; const providerConfig = await trySafe(async () => { - const instance = new constructor(connector); + const instance = new constructor(connector, tenantId); return instance.getConfig(); }); diff --git a/packages/core/src/sso/SamlConnector/utils.ts b/packages/core/src/sso/SamlConnector/utils.ts index 17c52aaaf..1527edf0d 100644 --- a/packages/core/src/sso/SamlConnector/utils.ts +++ b/packages/core/src/sso/SamlConnector/utils.ts @@ -1,4 +1,4 @@ -import * as validator from '@authenio/samlify-xsd-schema-validator'; +import * as validator from '@authenio/samlify-node-xmllint'; import { ConnectorError, ConnectorErrorCodes, socialUserInfoGuard } from '@logto/connector-kit'; import { type Optional, conditional } from '@silverhand/essentials'; import { got } from 'got'; diff --git a/packages/core/src/sso/SamlSsoConnector/index.test.ts b/packages/core/src/sso/SamlSsoConnector/index.test.ts index 33e59c73c..18ee8a2c2 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.test.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.test.ts @@ -14,15 +14,24 @@ describe('SamlSsoConnector', () => { expect(samlSsoConnectorFactory.configGuard).toBeDefined(); }); + it('constructor should work properly', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const createSamlSsoConnector = () => + new samlSsoConnectorFactory.constructor(mockSsoConnector, 'default_tenant'); + + expect(createSamlSsoConnector).not.toThrow(); + }); + it('constructor should throw error if config is invalid', () => { - const result = samlSsoConnectorFactory.configGuard.safeParse(mockSsoConnector.config); + const temporaryMockSsoConnector = { ...mockSsoConnector, config: { metadata: 123 } }; + const result = samlSsoConnectorFactory.configGuard.safeParse(temporaryMockSsoConnector.config); if (result.success) { throw new Error('Invalid config'); } const createSamlSsoConnector = () => { - return new samlSsoConnectorFactory.constructor(mockSsoConnector, 'http://localhost:3001/api'); + return new samlSsoConnectorFactory.constructor(temporaryMockSsoConnector, 'default_tenant'); }; expect(createSamlSsoConnector).toThrow( diff --git a/packages/core/src/sso/SamlSsoConnector/index.ts b/packages/core/src/sso/SamlSsoConnector/index.ts index 2f1cf868f..eedafeb8e 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -14,7 +14,6 @@ import { samlConnectorConfigGuard } from '../types/saml.js'; * @property data The SAML connector data from the database * * @method getConfig Get parsed SAML config along with it's metadata. Throws error if config is invalid. - * @method getAuthorizationUrl Get SAML auth URL. * @method getUserInfo Get social user info. */ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { @@ -50,17 +49,6 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { async getUserInfo(assertion: Record) { return this.parseSamlAssertion(assertion); } - - /** - * Get SAML auth URL. - * - * @param jti The current session id. - * - * @returns The SAML auth URL. - */ - async getAuthorizationUrl(jti: string) { - return this.getSingleSignOnUrl(jti); - } } export const samlSsoConnectorFactory: SingleSignOnFactory = { diff --git a/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts new file mode 100644 index 000000000..4bdf91036 --- /dev/null +++ b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts @@ -0,0 +1,29 @@ +import { type JsonObject } from '@logto/schemas'; + +import { logtoUrl } from '#src/constants.js'; + +const logtoIssuer = `${logtoUrl}/oidc`; + +const metadataXml = `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 mutable display name of the user.SubjectAn immutable, globally unique, non-reusable identifier of the user that is unique to the application for which a token is issued.Given NameFirst name of the user.SurnameLast name of the user.Display NameDisplay name of the user.Nick NameNick name of the user.Authentication InstantThe time (UTC) when the user is authenticated to Windows Azure Active Directory.Authentication MethodThe method that Windows Azure Active Directory uses to authenticate users.ObjectIdentifierPrimary identifier for the user in the directory. Immutable, globally unique, non-reusable.TenantIdIdentifier for the user's tenant.IdentityProviderIdentity provider for the user.EmailEmail address of the user.GroupsGroups of the user.External Access TokenAccess token issued by external identity provider.External Access Token ExpirationUTC expiration time of access token issued by external identity provider.External OpenID 2.0 IdentifierOpenID 2.0 identifier issued by external identity provider.GroupsOverageClaimIssued when number of user's group claims exceeds return limit.Role ClaimRoles that the user or Service Principal is attached toRoleTemplate Id ClaimRole template id of the Built-in Directory Roles that the user is a member ofhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4Nhttps://sts.windows.net/ac016212-4f8d-46c6-892c-57c90a255a02/https://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4N`; + +export const providerNames = ['OIDC', 'SAML']; +export const partialConfigAndProviderNames: Array<{ + providerName: string; + config: JsonObject; +}> = [ + { + providerName: 'OIDC', + config: { + clientId: 'foo', + clientSecret: 'foo', + issuer: logtoIssuer, + scope: 'openid', + }, + }, + { + providerName: 'SAML', + config: { + metadata: metadataXml, + }, + }, +]; diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index 56fee3291..58e015eb9 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -4,6 +4,7 @@ import { logtoConsoleUrl, logtoUrl, logtoCloudUrl } from '#src/constants.js'; const api = got.extend({ prefixUrl: new URL('/api', logtoUrl), + timeout: { response: 5000 }, // The default is 60s which is way too long for tests. }); export default api; diff --git a/packages/integration-tests/src/tests/api/sso-connectors.test.ts b/packages/integration-tests/src/tests/api/sso-connectors.test.ts index b1a6ecfb8..d7ea3848d 100644 --- a/packages/integration-tests/src/tests/api/sso-connectors.test.ts +++ b/packages/integration-tests/src/tests/api/sso-connectors.test.ts @@ -1,5 +1,9 @@ import { HTTPError } from 'got'; +import { + providerNames, + partialConfigAndProviderNames, +} from '#src/__mocks__/sso-connectors-mock.js'; import { getSsoConnectorFactories, createSsoConnector, @@ -9,9 +13,6 @@ import { patchSsoConnectorById, patchSsoConnectorConfigById, } from '#src/api/sso-connector.js'; -import { logtoUrl } from '#src/constants.js'; - -const logtoIssuer = `${logtoUrl}/oidc`; describe('sso-connector library', () => { it('should return sso-connector-factories', async () => { @@ -20,7 +21,13 @@ describe('sso-connector library', () => { expect(response).toHaveProperty('standardConnectors'); expect(response).toHaveProperty('providerConnectors'); - expect(response.standardConnectors.length).toBeGreaterThan(0); + expect(response.standardConnectors.length).toBe(2); + expect( + response.standardConnectors.find(({ providerName }) => providerName === 'OIDC') + ).toBeDefined(); + expect( + response.standardConnectors.find(({ providerName }) => providerName === 'SAML') + ).toBeDefined(); }); }); @@ -50,14 +57,14 @@ describe('post sso-connectors', () => { ).rejects.toThrow(HTTPError); }); - it('should create a new sso connector', async () => { + it.each(providerNames)('should create a new sso connector', async (providerName) => { const response = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'test', }); expect(response).toHaveProperty('id'); - expect(response).toHaveProperty('providerName', 'OIDC'); + expect(response).toHaveProperty('providerName', providerName); expect(response).toHaveProperty('connectorName', 'test'); expect(response).toHaveProperty('config', {}); expect(response).toHaveProperty('domains', []); @@ -67,48 +74,49 @@ describe('post sso-connectors', () => { await deleteSsoConnectorById(response.id); }); - it('should throw if invalid config is provided', async () => { + it.each(providerNames)('should throw if invalid config is provided', async (providerName) => { await expect( createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'test', config: { issuer: 23, + entityId: 123, }, }) ).rejects.toThrow(HTTPError); }); - it('should create a new sso connector with partial configs', async () => { - const data = { - providerName: 'OIDC', - connectorName: 'test', - config: { - clientId: 'foo', - issuer: 'https://test.com', - }, - domains: ['test.com'], - ssoOnly: true, - }; + it.each(partialConfigAndProviderNames)( + 'should create a new sso connector with partial configs', + async ({ providerName, config }) => { + const data = { + providerName, + connectorName: 'test', + config, + domains: ['test.com'], + ssoOnly: true, + }; - const response = await createSsoConnector(data); + const response = await createSsoConnector(data); - expect(response).toHaveProperty('id'); - expect(response).toHaveProperty('providerName', 'OIDC'); - expect(response).toHaveProperty('connectorName', 'test'); - expect(response).toHaveProperty('config', data.config); - expect(response).toHaveProperty('domains', data.domains); - expect(response).toHaveProperty('ssoOnly', data.ssoOnly); - expect(response).toHaveProperty('syncProfile', false); + expect(response).toHaveProperty('id'); + expect(response).toHaveProperty('providerName', providerName); + expect(response).toHaveProperty('connectorName', 'test'); + expect(response).toHaveProperty('config', data.config); + expect(response).toHaveProperty('domains', data.domains); + expect(response).toHaveProperty('ssoOnly', data.ssoOnly); + expect(response).toHaveProperty('syncProfile', false); - await deleteSsoConnectorById(response.id); - }); + await deleteSsoConnectorById(response.id); + } + ); }); describe('get sso-connectors', () => { - it('should return sso connectors', async () => { + it.each(providerNames)('should return sso connectors', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'test', }); @@ -120,8 +128,11 @@ describe('get sso-connectors', () => { expect(connector).toBeDefined(); expect(connector?.providerLogo).toBeDefined(); - // Invalid config - expect(connector?.providerConfig).toBeUndefined(); + // Empty config object is a valid SAML config. + if (providerName === 'OIDC') { + // Invalid config + expect(connector?.providerConfig).toBeUndefined(); + } await deleteSsoConnectorById(id); }); @@ -132,16 +143,16 @@ describe('get sso-connector by id', () => { await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError); }); - it('should return sso connector', async () => { + it.each(providerNames)('should return sso connector', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); const connector = await getSsoConnectorById(id); expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); + expect(connector).toHaveProperty('providerName', providerName); expect(connector).toHaveProperty('connectorName', 'integration_test connector'); expect(connector).toHaveProperty('config', {}); expect(connector).toHaveProperty('domains', []); @@ -157,9 +168,9 @@ describe('delete sso-connector by id', () => { await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError); }); - it('should delete sso connector', async () => { + it.each(providerNames)('should delete sso connector', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); @@ -178,9 +189,9 @@ describe('patch sso-connector by id', () => { ); }); - it('should patch sso connector without config', async () => { + it.each(providerNames)('should patch sso connector without config', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); @@ -191,7 +202,7 @@ describe('patch sso-connector by id', () => { }); expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); + expect(connector).toHaveProperty('providerName', providerName); expect(connector).toHaveProperty('connectorName', 'integration_test connector updated'); expect(connector).toHaveProperty('config', {}); expect(connector).toHaveProperty('domains', ['test.com']); @@ -201,9 +212,9 @@ describe('patch sso-connector by id', () => { await deleteSsoConnectorById(id); }); - it('should directly return if no changes are made', async () => { + it.each(providerNames)('should directly return if no changes are made', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); @@ -212,7 +223,7 @@ describe('patch sso-connector by id', () => { }); expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); + expect(connector).toHaveProperty('providerName', providerName); expect(connector).toHaveProperty('connectorName', 'integration_test connector'); expect(connector).toHaveProperty('config', {}); expect(connector).toHaveProperty('domains', []); @@ -222,17 +233,17 @@ describe('patch sso-connector by id', () => { await deleteSsoConnectorById(id); }); - it('should throw if invalid config is provided', async () => { + it.each(providerNames)('should throw if invalid config is provided', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', }); await expect( patchSsoConnectorById(id, { config: { - clientId: 'foo', - issuer: logtoIssuer, + issuer: 23, + entityId: 123, }, }) ).rejects.toThrow(HTTPError); @@ -240,36 +251,29 @@ describe('patch sso-connector by id', () => { await deleteSsoConnectorById(id); }); - it('should patch sso connector with config', async () => { - const { id } = await createSsoConnector({ - providerName: 'OIDC', - connectorName: 'integration_test connector', - }); + it.each(partialConfigAndProviderNames)( + 'should patch sso connector with config', + async ({ providerName, config }) => { + const { id } = await createSsoConnector({ + providerName, + connectorName: 'integration_test connector', + }); - const connector = await patchSsoConnectorById(id, { - connectorName: 'integration_test connector updated', - config: { - clientId: 'foo', - clientSecret: 'bar', - issuer: logtoIssuer, - scope: 'profile email', - }, - syncProfile: true, - }); + const connector = await patchSsoConnectorById(id, { + connectorName: 'integration_test connector updated', + config, + syncProfile: true, + }); - expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); - expect(connector).toHaveProperty('connectorName', 'integration_test connector updated'); - expect(connector).toHaveProperty('config', { - clientId: 'foo', - clientSecret: 'bar', - issuer: logtoIssuer, - scope: 'profile email openid', // Should merged with default scope openid - }); - expect(connector).toHaveProperty('syncProfile', true); + expect(connector).toHaveProperty('id', id); + expect(connector).toHaveProperty('providerName', providerName); + expect(connector).toHaveProperty('connectorName', 'integration_test connector updated'); + expect(connector).toHaveProperty('config', config); + expect(connector).toHaveProperty('syncProfile', true); - await deleteSsoConnectorById(id); - }); + await deleteSsoConnectorById(id); + } + ); }); describe('patch sso-connector config by id', () => { @@ -277,48 +281,42 @@ describe('patch sso-connector config by id', () => { await expect(patchSsoConnectorConfigById('invalid-id', {})).rejects.toThrow(HTTPError); }); - it('should throw if invalid config is provided', async () => { + it.each(providerNames)('should throw if invalid config is provided', async (providerName) => { const { id } = await createSsoConnector({ - providerName: 'OIDC', + providerName, connectorName: 'integration_test connector', config: { clientSecret: 'bar', + metadataType: 'URL', }, }); await expect( patchSsoConnectorConfigById(id, { - clientId: 'foo', + issuer: 23, + entityId: 123, }) ).rejects.toThrow(HTTPError); await deleteSsoConnectorById(id); }); - it('should patch sso connector config', async () => { - const { id } = await createSsoConnector({ - providerName: 'OIDC', - connectorName: 'integration_test connector', - config: { - clientId: 'foo', - }, - }); + it.each(partialConfigAndProviderNames)( + 'should patch sso connector config', + async ({ providerName, config }) => { + const { id } = await createSsoConnector({ + providerName, + connectorName: 'integration_test connector', + }); - const connector = await patchSsoConnectorConfigById(id, { - clientSecret: 'bar', - issuer: logtoIssuer, - }); + const connector = await patchSsoConnectorConfigById(id, config); - expect(connector).toHaveProperty('id', id); - expect(connector).toHaveProperty('providerName', 'OIDC'); - expect(connector).toHaveProperty('connectorName', 'integration_test connector'); - expect(connector).toHaveProperty('config', { - clientId: 'foo', - clientSecret: 'bar', - issuer: logtoIssuer, - scope: 'openid', - }); + expect(connector).toHaveProperty('id', id); + expect(connector).toHaveProperty('providerName', providerName); + expect(connector).toHaveProperty('connectorName', 'integration_test connector'); + expect(connector).toHaveProperty('config', config); - await deleteSsoConnectorById(id); - }); + await deleteSsoConnectorById(id); + } + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37da2546d..9e1f9782d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3121,9 +3121,9 @@ importers: packages/core: dependencies: - '@authenio/samlify-xsd-schema-validator': - specifier: ^1.0.5 - version: 1.0.5(samlify@2.8.10) + '@authenio/samlify-node-xmllint': + specifier: ^2.0.0 + version: 2.0.0(samlify@2.8.10) '@aws-sdk/client-s3': specifier: ^3.315.0 version: 3.315.0 @@ -4171,12 +4171,12 @@ packages: '@jridgewell/trace-mapping': 0.3.18 dev: true - /@authenio/samlify-xsd-schema-validator@1.0.5(samlify@2.8.10): - resolution: {integrity: sha512-HJjmjM1WbeB/z4nVbYEcmtIWTLPKqjrqRGEpC9lu7s03Usc4nxxfrJGjHgh3M8MvBJy4neVUoeM9rP4ym3GLgg==} + /@authenio/samlify-node-xmllint@2.0.0(samlify@2.8.10): + resolution: {integrity: sha512-V9cQ0CHqu3JwOmbSecGPUnzIES5kHxD00FEZKnWh90ksQUJG5/TscV2r9XLbKp7MlRMOSUfWxecM35xPSLFdSg==} peerDependencies: samlify: '>= 2.6.0' dependencies: - '@authenio/xsd-schema-validator': 0.7.3 + node-xmllint: 1.0.0 samlify: 2.8.10 dev: false @@ -4189,11 +4189,6 @@ packages: xpath: 0.0.32 dev: false - /@authenio/xsd-schema-validator@0.7.3: - resolution: {integrity: sha512-Jhc/Hxv90bacZr0Fv+u+PEb440zPh4mO6rw+bzEAIBiFLKCtRa/BvKGRxPdCAwsGRPuwl2hFqQGF+Lfz6Q8kFg==} - requiresBuild: true - dev: false - /@aws-crypto/crc32@3.0.0: resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} dependencies: @@ -16768,6 +16763,10 @@ packages: asn1: 0.2.6 dev: false + /node-xmllint@1.0.0: + resolution: {integrity: sha512-71UV2HRUP+djvHpdyatiuv+Y1o8hI4ZI7bMfuuoACMLR1JJCErM4WXAclNeHd6BgHXkqeqnnAk3wpDkSQWmFXw==} + dev: false + /nodemailer@6.9.1: resolution: {integrity: sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==} engines: {node: '>=6.0.0'}