0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

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
This commit is contained in:
simeng-li 2023-11-01 09:38:28 +08:00 committed by GitHub
parent 02edca0981
commit a982e997c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 199 additions and 53 deletions

View file

@ -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;

View file

@ -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,
},
]);

View file

@ -21,7 +21,7 @@ export type SignInExperienceLibrary = ReturnType<typeof createSignInExperienceLi
export const createSignInExperienceLibrary = (
queries: Queries,
{ getLogtoConnectors }: ConnectorLibrary,
{ getSsoConnectors }: SsoConnectorLibrary
{ getAvailableSsoConnectors }: SsoConnectorLibrary
) => {
const {
customPhrases: { findAllCustomLanguageTags },
@ -63,32 +63,19 @@ export const createSignInExperienceLibrary = (
return [];
}
const ssoConnectors = await getSsoConnectors();
const ssoConnectors = await getAvailableSsoConnectors();
return ssoConnectors.reduce<SsoConnectorMetadata[]>(
(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];
},
[]
}
);
};

View file

@ -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;

View file

@ -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,
};
};

View file

@ -26,7 +26,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
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<T extends IRouterParamContext>(
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();
}
);
}

View file

@ -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<Array<{ id: string; ssoOnly: boolean }>>();
};

View file

@ -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<string, SsoConnectorMetadata>();
const connectorIdMap = new Map<string, SsoConnectorMetadata & { ssoOnly: boolean }>();
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);
});
});

View file

@ -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<SignInExperience & { ssoConnectors: SsoConnectorMetadata[] }>();
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<SignInExperience & { ssoConnectors: SsoConnectorMetadata[] }>();
const { ssoConnectors } = signInExperience;
expect(ssoConnectors.find((connector) => connector.id === id)).toBeUndefined();
await deleteSsoConnectorById(id);
});
});
});

View file

@ -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(),
});