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:
parent
02edca0981
commit
a982e997c3
10 changed files with 199 additions and 53 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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];
|
||||
},
|
||||
[]
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }>>();
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue