From a982e997c3654f03796d016d2ceae8bb93c3c4ae Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 1 Nov 2023 09:38:28 +0800 Subject: [PATCH] feat(core): add get sso-connectors by email interaction api (#4795) * refactor(core,test,schemas): remove the domains property from sso connector in sie wellknown data remove the domains property from sso connector in sie wellknown data * refactor(core): extract getAvailableSsoConnectors library method extract getAvailableSsoConnectors library method * fix(core,test): should filter out empty domain connectors should filter out empty domain connectors * feat(core,test): add get sso-connectors by email interaction api add get sso-connectors by email interaction api * chore(core): update comments update comments --- packages/core/src/__mocks__/sso.ts | 17 ++++++++ .../sign-in-experience/index.test.ts | 34 +++++----------- .../src/libraries/sign-in-experience/index.ts | 25 +++--------- .../core/src/libraries/sso-connector.test.ts | 40 ++++++++++++++++++- packages/core/src/libraries/sso-connector.ts | 14 +++++++ .../src/routes/interaction/single-sign-on.ts | 33 ++++++++++++++- .../src/api/interaction-sso.ts | 16 ++++++++ .../single-sign-on/happy-path.test.ts | 34 ++++++++++++++-- .../src/tests/api/well-known.test.ts | 38 +++++++++++++++++- packages/schemas/src/types/sso-connector.ts | 1 - 10 files changed, 199 insertions(+), 53 deletions(-) diff --git a/packages/core/src/__mocks__/sso.ts b/packages/core/src/__mocks__/sso.ts index 655d40291..f70bf7477 100644 --- a/packages/core/src/__mocks__/sso.ts +++ b/packages/core/src/__mocks__/sso.ts @@ -14,3 +14,20 @@ export const mockSsoConnector = { ssoOnly: true, createdAt: Date.now(), } satisfies SsoConnector; + +export const wellConfiguredSsoConnector = { + id: 'mock-well-configured-sso-connector', + tenantId: 'mock-tenant', + providerName: SsoProviderName.OIDC, + connectorName: 'mock-connector-name', + config: { + clientId: 'foo', + clientSecret: 'bar', + issuer: 'https://foo.com', + }, + domains: ['foo.com'], + branding: {}, + syncProfile: true, + ssoOnly: true, + createdAt: Date.now(), +} satisfies SsoConnector; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index f48fe664b..b78568e4e 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -7,7 +7,7 @@ import { socialTarget02, mockSignInExperience, mockSocialConnectors, - mockSsoConnector, + wellConfiguredSsoConnector, } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; @@ -37,6 +37,7 @@ const { findDefaultSignInExperience, updateDefaultSignInExperience } = signInExp const ssoConnectorLibrary = { getSsoConnectors: jest.fn(), getSsoConnectorById: jest.fn(), + getAvailableSsoConnectors: jest.fn(), }; const { MockQueries } = await import('#src/test-utils/tenant.js'); @@ -129,38 +130,21 @@ describe('remove unavailable social connector targets', () => { }); describe('get sso connectors', () => { - it('should filter out sso connectors that has invalid config', async () => { - ssoConnectorLibrary.getSsoConnectors.mockResolvedValueOnce([mockSsoConnector]); + it('should return sso connectors metadata', async () => { getLogtoConnectors.mockResolvedValueOnce(mockSocialConnectors); findDefaultSignInExperience.mockResolvedValueOnce(mockSignInExperience); - const { ssoConnectors } = await getFullSignInExperience(); - expect(ssoConnectors).toEqual([]); - }); - - it('should return sso connectors with valid config', async () => { - getLogtoConnectors.mockResolvedValueOnce(mockSocialConnectors); - findDefaultSignInExperience.mockResolvedValueOnce(mockSignInExperience); - - const ssoConnector = { - ...mockSsoConnector, - config: { - clientId: 'mockClientId', - clientSecret: 'mockClientSecret', - issuer: 'mockIssuer', - }, - }; - - ssoConnectorLibrary.getSsoConnectors.mockResolvedValueOnce([ssoConnector]); + ssoConnectorLibrary.getAvailableSsoConnectors.mockResolvedValueOnce([ + wellConfiguredSsoConnector, + ]); const { ssoConnectors } = await getFullSignInExperience(); expect(ssoConnectors).toEqual([ { - id: ssoConnector.id, - connectorName: ssoConnector.connectorName, - domains: ssoConnector.domains, - logo: ssoConnectorFactories[ssoConnector.providerName].logo, + id: wellConfiguredSsoConnector.id, + connectorName: wellConfiguredSsoConnector.connectorName, + logo: ssoConnectorFactories[wellConfiguredSsoConnector.providerName].logo, darkLogo: undefined, }, ]); diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 98f9e8074..340ad2a4c 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -21,7 +21,7 @@ export type SignInExperienceLibrary = ReturnType { const { customPhrases: { findAllCustomLanguageTags }, @@ -63,32 +63,19 @@ export const createSignInExperienceLibrary = ( return []; } - const ssoConnectors = await getSsoConnectors(); + const ssoConnectors = await getAvailableSsoConnectors(); - return ssoConnectors.reduce( - (previous, connector): SsoConnectorMetadata[] => { - const { providerName, connectorName, config, id, branding, domains } = connector; + return ssoConnectors.map( + ({ providerName, connectorName, id, branding }): SsoConnectorMetadata => { const factory = ssoConnectorFactories[providerName]; - // Filter out sso connectors that has invalid config - const result = factory.configGuard.safeParse(config); - - if (!result.success) { - return previous; - } - - // Format the connector metadata for the client - const connectorMetadata: SsoConnectorMetadata = { + return { id, connectorName, - domains, logo: branding.logo ?? factory.logo, darkLogo: branding.darkLogo, }; - - return [...previous, connectorMetadata]; - }, - [] + } ); }; diff --git a/packages/core/src/libraries/sso-connector.test.ts b/packages/core/src/libraries/sso-connector.test.ts index 83dd92e56..422f7d108 100644 --- a/packages/core/src/libraries/sso-connector.test.ts +++ b/packages/core/src/libraries/sso-connector.test.ts @@ -1,4 +1,4 @@ -import { mockSsoConnector } from '#src/__mocks__/sso.js'; +import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; import RequestError from '#src/errors/RequestError/index.js'; import { MockQueries } from '#src/test-utils/tenant.js'; @@ -35,6 +35,44 @@ describe('SsoConnectorLibrary', () => { expect(connectors).toEqual([mockSsoConnector]); }); + it('getAvailableSsoConnectors() should filter sso connectors with invalid config', async () => { + const { getAvailableSsoConnectors } = ssoConnectorLibrary; + + findAllSsoConnectors.mockResolvedValueOnce([ + 2, + [ + { + ...mockSsoConnector, + domains: ['foo.com'], + }, + wellConfiguredSsoConnector, + ], + ]); + + const connectors = await getAvailableSsoConnectors(); + + expect(connectors).toEqual([wellConfiguredSsoConnector]); + }); + + it('getAvailableSsoConnectors() should filter sso connectors with empty domains', async () => { + const { getAvailableSsoConnectors } = ssoConnectorLibrary; + + findAllSsoConnectors.mockResolvedValueOnce([ + 2, + [ + { + ...mockSsoConnector, + config: wellConfiguredSsoConnector.config, + }, + wellConfiguredSsoConnector, + ], + ]); + + const connectors = await getAvailableSsoConnectors(); + + expect(connectors).toEqual([wellConfiguredSsoConnector]); + }); + it('getSsoConnectorById() should throw 404 if the connector is not supported', async () => { const { getSsoConnectorById } = ssoConnectorLibrary; diff --git a/packages/core/src/libraries/sso-connector.ts b/packages/core/src/libraries/sso-connector.ts index 7ba8e89ad..72469e86b 100644 --- a/packages/core/src/libraries/sso-connector.ts +++ b/packages/core/src/libraries/sso-connector.ts @@ -1,6 +1,7 @@ import { assert } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; +import { ssoConnectorFactories } from '#src/sso/index.js'; import { type SupportedSsoConnector } from '#src/sso/types/index.js'; import { isSupportedSsoConnector } from '#src/sso/utils.js'; import type Queries from '#src/tenants/Queries.js'; @@ -18,6 +19,18 @@ export const createSsoConnectorLibrary = (queries: Queries) => { ); }; + const getAvailableSsoConnectors = async () => { + const connectors = await getSsoConnectors(); + + return connectors.filter(({ providerName, config, domains }) => { + const factory = ssoConnectorFactories[providerName]; + const hasValidConfig = factory.configGuard.safeParse(config).success; + const hasValidDomains = domains.length > 0; + + return hasValidConfig && hasValidDomains; + }); + }; + const getSsoConnectorById = async (id: string) => { const connector = await ssoConnectors.findById(id); @@ -35,6 +48,7 @@ export const createSsoConnectorLibrary = (queries: Queries) => { return { getSsoConnectors, + getAvailableSsoConnectors, getSsoConnectorById, }; }; diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index 7809b5dfd..35a370410 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -26,7 +26,7 @@ export default function singleSignOnRoutes( libraries: { ssoConnector }, } = tenant; - // Create Sso authorization url for user interaction + // Create SSO authorization url for user interaction router.post( `${interactionPrefix}/${ssoPath}/:connectorId/authentication`, koaGuard({ @@ -100,4 +100,35 @@ export default function singleSignOnRoutes( return next(); } ); + + // Get the available SSO connectors for the user to choose from by a given email + router.get( + `${interactionPrefix}/${ssoPath}/connectors`, + koaGuard({ + query: z.object({ + email: z.string().email(), + }), + status: [200, 400], + response: z.array( + z.object({ + id: z.string(), + ssoOnly: z.boolean(), + }) + ), + }), + async (ctx, next) => { + const { email } = ctx.guard.query; + const connectors = await ssoConnector.getAvailableSsoConnectors(); + + const domain = email.split('@')[1]; + + assertThat(domain, new RequestError({ code: 'guard.invalid_input', status: 400, email })); + + const availableConnectors = connectors.filter(({ domains }) => domains.includes(domain)); + + ctx.body = availableConnectors.map(({ id, ssoOnly }) => ({ id, ssoOnly })); + + return next(); + } + ); } diff --git a/packages/integration-tests/src/api/interaction-sso.ts b/packages/integration-tests/src/api/interaction-sso.ts index 429c99d25..3fd1b74f4 100644 --- a/packages/integration-tests/src/api/interaction-sso.ts +++ b/packages/integration-tests/src/api/interaction-sso.ts @@ -19,3 +19,19 @@ export const getSsoAuthorizationUrl = async ( }) .json<{ redirectTo: string }>(); }; + +export const getSsoConnectorsByEmail = async ( + cookie: string, + data: { + email: string; + } +) => { + return api + .get(`interaction/${ssoPath}/connectors`, { + headers: { cookie }, + searchParams: { + email: data.email, + }, + }) + .json>(); +}; diff --git a/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts index f40106c85..f975863ba 100644 --- a/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts @@ -1,21 +1,22 @@ import { InteractionEvent, type SsoConnectorMetadata } from '@logto/schemas'; -import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js'; +import { getSsoAuthorizationUrl, getSsoConnectorsByEmail } from '#src/api/interaction-sso.js'; import { putInteraction } from '#src/api/interaction.js'; import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; import { ProviderName, logtoUrl } from '#src/constants.js'; import { initClient } from '#src/helpers/client.js'; describe('Single Sign On Happy Path', () => { - const connectorIdMap = new Map(); + const connectorIdMap = new Map(); const state = 'foo_state'; const redirectUri = 'http://foo.dev/callback'; beforeAll(async () => { - const { id, connectorName, domains } = await createSsoConnector({ + const { id, connectorName, ssoOnly } = await createSsoConnector({ providerName: ProviderName.OIDC, connectorName: 'test-oidc', + domains: ['foo.com'], config: { clientId: 'foo', clientSecret: 'bar', @@ -23,7 +24,7 @@ describe('Single Sign On Happy Path', () => { }, }); - connectorIdMap.set(id, { id, connectorName, domains, logo: '' }); + connectorIdMap.set(id, { id, connectorName, ssoOnly, logo: '' }); }); afterAll(async () => { @@ -48,4 +49,29 @@ describe('Single Sign On Happy Path', () => { expect(response.redirectTo.indexOf(logtoUrl)).not.toBe(-1); expect(response.redirectTo.indexOf(state)).not.toBe(-1); }); + + it('should get sso connectors with given email properly', async () => { + const client = await initClient(); + + const response = await client.send(getSsoConnectorsByEmail, { + email: 'bar@foo.com', + }); + + expect(response.length).toBeGreaterThan(0); + + for (const connector of response) { + expect(connectorIdMap.has(connector.id)).toBe(true); + expect(connector.ssoOnly).toEqual(connectorIdMap.get(connector.id)!.ssoOnly); + } + }); + + it('should return empty array if no sso connectors found', async () => { + const client = await initClient(); + + const response = await client.send(getSsoConnectorsByEmail, { + email: 'foo@logto-invalid.com', + }); + + expect(response.length).toBe(0); + }); }); diff --git a/packages/integration-tests/src/tests/api/well-known.test.ts b/packages/integration-tests/src/tests/api/well-known.test.ts index 25bd99cc0..21a229879 100644 --- a/packages/integration-tests/src/tests/api/well-known.test.ts +++ b/packages/integration-tests/src/tests/api/well-known.test.ts @@ -61,6 +61,7 @@ describe('.well-known api', () => { const newOIDCSsoConnector = { providerName: 'OIDC', connectorName: 'OIDC sso connector', + domains: ['logto.io'], branding: { logo: 'https://logto.io/oidc-logo.png', darkLogo: 'https://logto.io/oidc-dark-logo.png', @@ -73,7 +74,7 @@ describe('.well-known api', () => { }; it('should get the sso connectors in sign-in experience', async () => { - const { id, connectorName, domains } = await createSsoConnector(newOIDCSsoConnector); + const { id, connectorName } = await createSsoConnector(newOIDCSsoConnector); const signInExperience = await api .get('.well-known/sign-in-exp') @@ -88,12 +89,45 @@ describe('.well-known api', () => { expect(newCreatedConnector).toMatchObject({ id, connectorName, - domains, logo: newOIDCSsoConnector.branding.logo, darkLogo: newOIDCSsoConnector.branding.darkLogo, }); await deleteSsoConnectorById(id); }); + + it('should filter out the sso connectors with invalid config', async () => { + const { id } = await createSsoConnector({ + ...newOIDCSsoConnector, + config: {}, + }); + + const signInExperience = await api + .get('.well-known/sign-in-exp') + .json(); + + const { ssoConnectors } = signInExperience; + + expect(ssoConnectors.find((connector) => connector.id === id)).toBeUndefined(); + + await deleteSsoConnectorById(id); + }); + + it('should filter out the sso connectors with empty domains', async () => { + const { id } = await createSsoConnector({ + ...newOIDCSsoConnector, + domains: [], + }); + + const signInExperience = await api + .get('.well-known/sign-in-exp') + .json(); + + const { ssoConnectors } = signInExperience; + + expect(ssoConnectors.find((connector) => connector.id === id)).toBeUndefined(); + + await deleteSsoConnectorById(id); + }); }); }); diff --git a/packages/schemas/src/types/sso-connector.ts b/packages/schemas/src/types/sso-connector.ts index 9fdd380af..25f741f8f 100644 --- a/packages/schemas/src/types/sso-connector.ts +++ b/packages/schemas/src/types/sso-connector.ts @@ -6,7 +6,6 @@ import { z } from 'zod'; export const ssoConnectorMetadataGuard = z.object({ id: z.string(), connectorName: z.string(), - domains: z.array(z.string()), logo: z.string(), darkLogo: z.string().optional(), });