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

feat(core): update sso connector util functions, apis and integration tests

This commit is contained in:
Darcy Ye 2023-11-02 11:21:00 +08:00
parent b5a9633f03
commit f8450a50ae
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
6 changed files with 224 additions and 147 deletions

View file

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

View file

@ -22,6 +22,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
tenant: TenantContext
) {
const {
id: tenantId,
provider,
libraries: { ssoConnector },
} = tenant;
@ -43,7 +44,8 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
}),
}),
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<T extends IRouterParamContext>(
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)
);

View file

@ -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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...args: Rout
);
// Fetch provider details for the connector
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId);
ctx.body = connectorWithProviderDetails;

View file

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

View file

@ -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<SsoProviderName>,
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<SsoConnectorWithProviderConfig> => {
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();
});

View file

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