mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add OIDC SSO connector class (#4701)
* feat(core): implement oidc and single sign on connector class init oidc and single sign on connecter class * refactor(core): refactor the structure of single sign-on classes refactor the structure of single sign-on classes * chore(core): provide more comments provide more comments * feat(core): add sso-connector-factories api (#4708) * feat(core): add sso-connector-factories api add sso-connector-factories api * fix(test): remove hard code connector name remove hard code connector name * feat(core): add POST sso-connectors api (#4719) * feat(core): add POST sso-connectors api add POST sso-connectors api * chore(core): add some comments add some comments * test(core): add post sso connectors integration tests add post sso connectors integration tests * feat(core): add GET sso-connectors api (#4723) * feat(core): add GET sso-connectors api add GET sso-connectors api * test(core): add tests add tests * test(core): add ut add ut * fix(test): remove console statement remove console statement * feat(core): implement get sso-connector by id endpoint (#4730) * feat(core): implement get sso-connector by id endpoint implement get sso-connector by id endpoint * feat(core): implement delete sso-connector by id endpoint (#4733) * feat(core): implement delete sso-connector by id endpoint implement delete sso-connector by id endpoint * feat(core): implement patch sso-connectors api (#4734) * feat(core): implement patch sso-connectors api implement patch sso-connectors api * fix(core): avoid patch api empty update case avoid patch api empty update case * feat(core): implement patch sso-connector config api (#4736) implement patch sso-connector config api * fix(test): replace SAML provider name with dummy name replace SAML provider name with dummy name as we are going to implement the SAML connector soon * fix(core): fix rebase error of findAll query output type fix rebase error of the findAll query output type
This commit is contained in:
parent
233ccff0fa
commit
2b15b13bbf
24 changed files with 1540 additions and 9 deletions
|
@ -45,6 +45,7 @@
|
||||||
"@simplewebauthn/server": "^8.2.0",
|
"@simplewebauthn/server": "^8.2.0",
|
||||||
"@withtyped/client": "^0.7.22",
|
"@withtyped/client": "^0.7.22",
|
||||||
"camelcase": "^8.0.0",
|
"camelcase": "^8.0.0",
|
||||||
|
"camelcase-keys": "^9.0.0",
|
||||||
"chalk": "^5.0.0",
|
"chalk": "^5.0.0",
|
||||||
"clean-deep": "^3.4.0",
|
"clean-deep": "^3.4.0",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
|
14
packages/core/src/__mocks__/sso.ts
Normal file
14
packages/core/src/__mocks__/sso.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { type SsoConnector } from '@logto/schemas';
|
||||||
|
|
||||||
|
export const mockSsoConnector: SsoConnector = {
|
||||||
|
id: 'mock-sso-connector',
|
||||||
|
tenantId: 'mock-tenant',
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'mock-connector-name',
|
||||||
|
config: {},
|
||||||
|
domains: [],
|
||||||
|
branding: {},
|
||||||
|
syncProfile: true,
|
||||||
|
ssoOnly: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
19
packages/core/src/queries/sso-connectors.ts
Normal file
19
packages/core/src/queries/sso-connectors.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import {
|
||||||
|
type CreateSsoConnector,
|
||||||
|
type SsoConnector,
|
||||||
|
type SsoConnectorKeys,
|
||||||
|
SsoConnectors,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { type CommonQueryMethods } from 'slonik';
|
||||||
|
|
||||||
|
import SchemaQueries from '#src/utils/SchemaQueries.js';
|
||||||
|
|
||||||
|
export default class SsoConnectorQueries extends SchemaQueries<
|
||||||
|
SsoConnectorKeys,
|
||||||
|
CreateSsoConnector,
|
||||||
|
SsoConnector
|
||||||
|
> {
|
||||||
|
constructor(pool: CommonQueryMethods) {
|
||||||
|
super(pool, SsoConnectors);
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ import resourceRoutes from './resource.js';
|
||||||
import roleRoutes from './role.js';
|
import roleRoutes from './role.js';
|
||||||
import roleScopeRoutes from './role.scope.js';
|
import roleScopeRoutes from './role.scope.js';
|
||||||
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
||||||
|
import ssoConnectors from './sso-connector/index.js';
|
||||||
import statusRoutes from './status.js';
|
import statusRoutes from './status.js';
|
||||||
import swaggerRoutes from './swagger.js';
|
import swaggerRoutes from './swagger.js';
|
||||||
import type { AnonymousRouter, AuthedRouter } from './types.js';
|
import type { AnonymousRouter, AuthedRouter } from './types.js';
|
||||||
|
@ -59,6 +60,7 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
userAssetsRoutes(managementRouter, tenant);
|
userAssetsRoutes(managementRouter, tenant);
|
||||||
domainRoutes(managementRouter, tenant);
|
domainRoutes(managementRouter, tenant);
|
||||||
organizationRoutes(managementRouter, tenant);
|
organizationRoutes(managementRouter, tenant);
|
||||||
|
ssoConnectors(managementRouter, tenant);
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
|
|
280
packages/core/src/routes/sso-connector/index.ts
Normal file
280
packages/core/src/routes/sso-connector/index.ts
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
import { SsoConnectors, jsonObjectGuard } from '@logto/schemas';
|
||||||
|
import { generateStandardShortId } from '@logto/shared';
|
||||||
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import { ssoConnectorFactories } from '#src/sso/index.js';
|
||||||
|
import { SsoProviderName } from '#src/sso/types/index.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';
|
||||||
|
import {
|
||||||
|
parseFactoryDetail,
|
||||||
|
isSupportedSsoProvider,
|
||||||
|
parseConnectorConfig,
|
||||||
|
fetchConnectorProviderDetails,
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
export default function singleSignOnRoutes<T extends AuthedRouter>(...args: RouterInitArgs<T>) {
|
||||||
|
const [
|
||||||
|
router,
|
||||||
|
{
|
||||||
|
queries: { ssoConnectors },
|
||||||
|
},
|
||||||
|
] = args;
|
||||||
|
|
||||||
|
const pathname = `/${tableToPathname(SsoConnectors.table)}`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Get all supported single sign on connector factory details
|
||||||
|
- standardConnectors: OIDC, SAML, etc.
|
||||||
|
- providerConnectors: Google, Okta, etc.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/sso-connector-factories',
|
||||||
|
koaGuard({
|
||||||
|
response: connectorFactoriesResponseGuard,
|
||||||
|
status: [200],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { locale } = ctx;
|
||||||
|
const factories = Object.values(ssoConnectorFactories);
|
||||||
|
const standardConnectors = new Set<ConnectorFactoryDetail>();
|
||||||
|
const providerConnectors = new Set<ConnectorFactoryDetail>();
|
||||||
|
|
||||||
|
for (const factory of factories) {
|
||||||
|
if ([SsoProviderName.OIDC].includes(factory.providerName)) {
|
||||||
|
standardConnectors.add(parseFactoryDetail(factory, locale));
|
||||||
|
} else {
|
||||||
|
providerConnectors.add(parseFactoryDetail(factory, locale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
standardConnectors: [...standardConnectors],
|
||||||
|
providerConnectors: [...providerConnectors],
|
||||||
|
};
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Create a new single sign on connector */
|
||||||
|
router.post(
|
||||||
|
pathname,
|
||||||
|
koaGuard({
|
||||||
|
body: ssoConnectorCreateGuard,
|
||||||
|
response: SsoConnectors.guard,
|
||||||
|
status: [200, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { body } = ctx.guard;
|
||||||
|
const { providerName, connectorName, config, ...rest } = body;
|
||||||
|
|
||||||
|
// TODO: @simeng-li new SSO error code
|
||||||
|
if (!isSupportedSsoProvider(providerName)) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'connector.not_found',
|
||||||
|
type: providerName,
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Validate the connector config if it's provided.
|
||||||
|
Allow partial config DB insert
|
||||||
|
*/
|
||||||
|
const parsedConfig = parseConnectorConfig(providerName, config);
|
||||||
|
const connectorId = generateStandardShortId();
|
||||||
|
|
||||||
|
const connector = await ssoConnectors.insert({
|
||||||
|
id: connectorId,
|
||||||
|
providerName,
|
||||||
|
connectorName,
|
||||||
|
...conditional(config && { config: parsedConfig }),
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = connector;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Get all single sign on connectors */
|
||||||
|
router.get(
|
||||||
|
pathname,
|
||||||
|
koaGuard({
|
||||||
|
response: ssoConnectorWithProviderConfigGuard.array(),
|
||||||
|
status: [200],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
// Query all connectors
|
||||||
|
const [_, entities] = await ssoConnectors.findAll();
|
||||||
|
|
||||||
|
// Fetch provider details for each connector
|
||||||
|
const connectorsWithProviderDetails = await Promise.all(
|
||||||
|
entities.map(async (connector) => fetchConnectorProviderDetails(connector))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter out unsupported connectors
|
||||||
|
ctx.body = connectorsWithProviderDetails.filter(Boolean);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Get a single sign on connector by id */
|
||||||
|
router.get(
|
||||||
|
`${pathname}/:id`,
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string().min(1) }),
|
||||||
|
response: ssoConnectorWithProviderConfigGuard,
|
||||||
|
status: [200, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
// Fetch the connector
|
||||||
|
const connector = await ssoConnectors.findById(id);
|
||||||
|
|
||||||
|
// Fetch provider details for the connector
|
||||||
|
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
// Return 404 if the connector is not found
|
||||||
|
if (!connectorWithProviderDetails) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'connector.not_found',
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = connectorWithProviderDetails;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Delete a single sign on connector by id */
|
||||||
|
router.delete(
|
||||||
|
`${pathname}/:id`,
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string().min(1) }),
|
||||||
|
status: [204, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
// Delete the connector
|
||||||
|
await ssoConnectors.deleteById(id);
|
||||||
|
ctx.status = 204;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Patch update a single sign on connector by id */
|
||||||
|
router.patch(
|
||||||
|
`${pathname}/:id`,
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string().min(1) }),
|
||||||
|
body: ssoConnectorPatchGuard,
|
||||||
|
response: ssoConnectorWithProviderConfigGuard,
|
||||||
|
status: [200, 404, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
const { body } = ctx.guard;
|
||||||
|
|
||||||
|
// Fetch the connector
|
||||||
|
const originalConnector = await ssoConnectors.findById(id);
|
||||||
|
const { providerName } = originalConnector;
|
||||||
|
|
||||||
|
// Return 422 if the connector provider is not supported
|
||||||
|
if (!isSupportedSsoProvider(providerName)) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'connector.not_found',
|
||||||
|
type: providerName,
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { config, ...rest } = body;
|
||||||
|
|
||||||
|
// Validate the connector config if it's provided
|
||||||
|
const parsedConfig = parseConnectorConfig(providerName, config);
|
||||||
|
|
||||||
|
// Check if there's any valid update
|
||||||
|
const hasValidUpdate = parsedConfig ?? Object.keys(rest).length > 0;
|
||||||
|
|
||||||
|
// Patch update the connector only if there's any valid update
|
||||||
|
const connector = hasValidUpdate
|
||||||
|
? await ssoConnectors.updateById(id, {
|
||||||
|
...conditional(parsedConfig && { config: parsedConfig }),
|
||||||
|
...rest,
|
||||||
|
})
|
||||||
|
: originalConnector;
|
||||||
|
|
||||||
|
// Fetch provider details for the connector
|
||||||
|
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
ctx.body = connectorWithProviderDetails;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Patch update a single sign on connector's config by id */
|
||||||
|
router.patch(
|
||||||
|
`${pathname}/:id/config`,
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string().min(1) }),
|
||||||
|
body: jsonObjectGuard,
|
||||||
|
response: ssoConnectorWithProviderConfigGuard,
|
||||||
|
status: [200, 404, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
const { body } = ctx.guard;
|
||||||
|
|
||||||
|
// Fetch the connector
|
||||||
|
const { providerName, config } = await ssoConnectors.findById(id);
|
||||||
|
|
||||||
|
// Return 422 if the connector provider is not supported
|
||||||
|
if (!isSupportedSsoProvider(providerName)) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'connector.not_found',
|
||||||
|
type: providerName,
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the connector config
|
||||||
|
const parsedConfig = parseConnectorConfig(providerName, body);
|
||||||
|
|
||||||
|
// Patch update the connector config
|
||||||
|
const connector = await ssoConnectors.updateById(id, {
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
...parsedConfig,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch provider details for the connector
|
||||||
|
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
ctx.body = connectorWithProviderDetails;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
48
packages/core/src/routes/sso-connector/type.ts
Normal file
48
packages/core/src/routes/sso-connector/type.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { SsoConnectors } from '@logto/schemas';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { SsoProviderName } from '#src/sso/types/index.js';
|
||||||
|
|
||||||
|
const connectorFactoryDetailGuard = z.object({
|
||||||
|
providerName: z.nativeEnum(SsoProviderName),
|
||||||
|
logo: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ConnectorFactoryDetail = z.infer<typeof connectorFactoryDetailGuard>;
|
||||||
|
|
||||||
|
export const connectorFactoriesResponseGuard = z.object({
|
||||||
|
standardConnectors: z.array(connectorFactoryDetailGuard),
|
||||||
|
providerConnectors: z.array(connectorFactoryDetailGuard),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ssoConnectorCreateGuard = SsoConnectors.createGuard
|
||||||
|
.pick({
|
||||||
|
config: true,
|
||||||
|
domains: true,
|
||||||
|
branding: true,
|
||||||
|
syncProfile: true,
|
||||||
|
ssoOnly: true,
|
||||||
|
})
|
||||||
|
// Provider name and connector name are required for creating a connector
|
||||||
|
.merge(SsoConnectors.guard.pick({ providerName: true, connectorName: true }));
|
||||||
|
|
||||||
|
export const ssoConnectorWithProviderConfigGuard = SsoConnectors.guard.merge(
|
||||||
|
z.object({
|
||||||
|
providerLogo: z.string(),
|
||||||
|
providerConfig: z.record(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SsoConnectorWithProviderConfig = z.infer<typeof ssoConnectorWithProviderConfigGuard>;
|
||||||
|
|
||||||
|
export const ssoConnectorPatchGuard = SsoConnectors.guard
|
||||||
|
.pick({
|
||||||
|
config: true,
|
||||||
|
domains: true,
|
||||||
|
branding: true,
|
||||||
|
syncProfile: true,
|
||||||
|
ssoOnly: true,
|
||||||
|
connectorName: true,
|
||||||
|
})
|
||||||
|
.partial();
|
112
packages/core/src/routes/sso-connector/utils.test.ts
Normal file
112
packages/core/src/routes/sso-connector/utils.test.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
|
|
||||||
|
import { mockSsoConnector } from '#src/__mocks__/sso.js';
|
||||||
|
import { SsoProviderName } from '#src/sso/types/index.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
|
const fetchOidcConfig = jest.fn();
|
||||||
|
|
||||||
|
await mockEsmWithActual('#src/sso/OidcConnector/utils.js', () => ({
|
||||||
|
fetchOidcConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ssoConnectorFactories } = await import('#src/sso/index.js');
|
||||||
|
const { isSupportedSsoProvider, parseFactoryDetail, fetchConnectorProviderDetails } = await import(
|
||||||
|
'./utils.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('isSupportedSsoProvider', () => {
|
||||||
|
it.each(Object.values(SsoProviderName))('should return true for %s', (providerName) => {
|
||||||
|
expect(isSupportedSsoProvider(providerName)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for unknown provider', () => {
|
||||||
|
expect(isSupportedSsoProvider('unknown-provider')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFactoryDetail', () => {
|
||||||
|
it.each(Object.values(SsoProviderName))('should return correct detail for %s', (providerName) => {
|
||||||
|
const { logo, description } = ssoConnectorFactories[providerName];
|
||||||
|
const detail = parseFactoryDetail(ssoConnectorFactories[providerName], 'en');
|
||||||
|
|
||||||
|
expect(detail).toEqual({
|
||||||
|
providerName,
|
||||||
|
logo,
|
||||||
|
description: description.en,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(Object.values(SsoProviderName))(
|
||||||
|
'should return correct detail for %s with unknown locale',
|
||||||
|
(providerName) => {
|
||||||
|
const { logo, description } = ssoConnectorFactories[providerName];
|
||||||
|
const detail = parseFactoryDetail(ssoConnectorFactories[providerName], 'zh');
|
||||||
|
|
||||||
|
expect(detail).toEqual({
|
||||||
|
providerName,
|
||||||
|
logo,
|
||||||
|
description: description.en,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchConnectorProviderDetails', () => {
|
||||||
|
it('should return undefined for unsupported provider', async () => {
|
||||||
|
const connector = { ...mockSsoConnector, providerName: 'unknown-provider' };
|
||||||
|
const result = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('providerConfig should be undefined if connector config is invalid', async () => {
|
||||||
|
const connector = { ...mockSsoConnector, config: { clientId: 'foo' } };
|
||||||
|
const result = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
...connector,
|
||||||
|
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchOidcConfig).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('providerConfig should be undefined if failed to fetch config', async () => {
|
||||||
|
const connector = {
|
||||||
|
...mockSsoConnector,
|
||||||
|
config: { clientId: 'foo', clientSecret: 'bar', issuer: 'http://example.com' },
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOidcConfig.mockRejectedValueOnce(new Error('mock-error'));
|
||||||
|
const result = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
...connector,
|
||||||
|
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct details for supported provider', async () => {
|
||||||
|
const connector = {
|
||||||
|
...mockSsoConnector,
|
||||||
|
config: { clientId: 'foo', clientSecret: 'bar', issuer: 'http://example.com' },
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOidcConfig.mockResolvedValueOnce({ tokenEndpoint: 'http://example.com/token' });
|
||||||
|
const result = await fetchConnectorProviderDetails(connector);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
...connector,
|
||||||
|
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
||||||
|
providerConfig: {
|
||||||
|
...connector.config,
|
||||||
|
scope: 'openid', // Default scope
|
||||||
|
tokenEndpoint: 'http://example.com/token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
80
packages/core/src/routes/sso-connector/utils.ts
Normal file
80
packages/core/src/routes/sso-connector/utils.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { type I18nPhrases } from '@logto/connector-kit';
|
||||||
|
import { type JsonObject, type SsoConnector } from '@logto/schemas';
|
||||||
|
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 } from '#src/sso/types/index.js';
|
||||||
|
|
||||||
|
import { type SsoConnectorWithProviderConfig } from './type.js';
|
||||||
|
|
||||||
|
const isKeyOfI18nPhrases = (key: string, phrases: I18nPhrases): key is keyof I18nPhrases =>
|
||||||
|
key in phrases;
|
||||||
|
|
||||||
|
export const isSupportedSsoProvider = (providerName: string): providerName is SsoProviderName =>
|
||||||
|
providerName in ssoConnectorFactories;
|
||||||
|
|
||||||
|
export const parseFactoryDetail = (
|
||||||
|
factory: SingleSignOnFactory<SsoProviderName>,
|
||||||
|
locale: string
|
||||||
|
) => {
|
||||||
|
const { providerName, logo, description } = factory;
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerName,
|
||||||
|
logo,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- falsy value expected
|
||||||
|
description: (isKeyOfI18nPhrases(locale, description) && description[locale]) || description.en,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Validate and partially parse the connector config if it's provided.
|
||||||
|
*/
|
||||||
|
export const parseConnectorConfig = (providerName: SsoProviderName, config?: JsonObject) => {
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factory = ssoConnectorFactories[providerName];
|
||||||
|
|
||||||
|
const result = factory.configGuard.partial().safeParse(config);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'connector.invalid_config',
|
||||||
|
status: 422,
|
||||||
|
details: result.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchConnectorProviderDetails = async (
|
||||||
|
connector: SsoConnector
|
||||||
|
): Promise<SsoConnectorWithProviderConfig | undefined> => {
|
||||||
|
const { providerName } = connector;
|
||||||
|
|
||||||
|
// Return undefined if the provider is not supported
|
||||||
|
if (!isSupportedSsoProvider(providerName)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { logo, constructor } = ssoConnectorFactories[providerName];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Safely fetch and parse the detailed connector config from provider.
|
||||||
|
Return undefined if failed to fetch or parse the config.
|
||||||
|
*/
|
||||||
|
const providerConfig = await trySafe(async () => {
|
||||||
|
const instance = new constructor(connector);
|
||||||
|
return instance.getConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...connector,
|
||||||
|
providerLogo: logo,
|
||||||
|
...conditional(providerConfig && { providerConfig }),
|
||||||
|
};
|
||||||
|
};
|
125
packages/core/src/sso/OidcConnector/index.ts
Normal file
125
packages/core/src/sso/OidcConnector/index.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorErrorCodes,
|
||||||
|
type GetSession,
|
||||||
|
type SetSession,
|
||||||
|
} from '@logto/connector-kit';
|
||||||
|
import { generateStandardId } from '@logto/shared/universal';
|
||||||
|
import { assert, conditional } from '@silverhand/essentials';
|
||||||
|
import snakecaseKeys from 'snakecase-keys';
|
||||||
|
|
||||||
|
import { type BaseOidcConfig, type BasicOidcConnectorConfig } from '../types/oidc.js';
|
||||||
|
|
||||||
|
import { fetchOidcConfig, fetchToken, getIdTokenClaims } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC connector
|
||||||
|
*
|
||||||
|
* @remark General connector for OIDC provider.
|
||||||
|
* This class provides the basic functionality to connect with a OIDC provider.
|
||||||
|
* All the OIDC single sign-on connector should extend this class.
|
||||||
|
* @see @logto/connector-kit.
|
||||||
|
*
|
||||||
|
* @property config The OIDC connector config
|
||||||
|
* @method getOidcConfig Fetch the full-list of OIDC config from the issuer. Throws error if config is invalid
|
||||||
|
* @method getAuthorizationUrl Generate the authorization URL for the OIDC provider
|
||||||
|
* @method getUserInfo Handle the sign-in callback from the OIDC provider and return the user info
|
||||||
|
*/
|
||||||
|
class OidcConnector {
|
||||||
|
constructor(private readonly config: BasicOidcConnectorConfig) {}
|
||||||
|
|
||||||
|
/* Fetch the full-list of OIDC config from the issuer. Throws error if config is invalid */
|
||||||
|
getOidcConfig = async (): Promise<BaseOidcConfig> => {
|
||||||
|
const { issuer } = this.config;
|
||||||
|
|
||||||
|
const oidcConfig = await fetchOidcConfig(issuer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...this.config,
|
||||||
|
...oidcConfig,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the authorization URL for the OIDC provider
|
||||||
|
*
|
||||||
|
* @param oidcQueryParams The query params for the OIDC provider
|
||||||
|
* @param oidcQueryParams.state The state generated by Logto experience client
|
||||||
|
* @param oidcQueryParams.redirectUri The redirect uri for the OIDC provider
|
||||||
|
* @param setSession Set the connector session data to the oidc provider session storage. @see @logto/connector-kit
|
||||||
|
*/
|
||||||
|
getAuthorizationUrl = async (
|
||||||
|
{ state, redirectUri }: { state: string; redirectUri: string },
|
||||||
|
setSession: SetSession
|
||||||
|
) => {
|
||||||
|
assert(
|
||||||
|
setSession,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
|
||||||
|
message: 'Connector session storage is not implemented.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const oidcConfig = await this.getOidcConfig();
|
||||||
|
const nonce = generateStandardId();
|
||||||
|
|
||||||
|
await setSession({ nonce, redirectUri });
|
||||||
|
|
||||||
|
const queryParameters = new URLSearchParams({
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
...snakecaseKeys({
|
||||||
|
clientId: oidcConfig.clientId,
|
||||||
|
responseType: 'code',
|
||||||
|
redirectUri,
|
||||||
|
}),
|
||||||
|
scope: oidcConfig.scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${oidcConfig.authorizationEndpoint}?${queryParameters.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the sign-in callback from the OIDC provider and return the user info
|
||||||
|
*
|
||||||
|
* @param data unknown oidc authorization response
|
||||||
|
* @param getSession Get the connector session data from the oidc provider session storage. @see @logto/connector-kit
|
||||||
|
* @returns The user info from the OIDC provider
|
||||||
|
* @remark Forked from @logto/oidc-connector
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
getUserInfo = async (data: unknown, getSession: GetSession) => {
|
||||||
|
assert(
|
||||||
|
getSession,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
|
||||||
|
message: 'Connector session storage not found',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const oidcConfig = await this.getOidcConfig();
|
||||||
|
const { redirectUri, nonce } = await getSession();
|
||||||
|
|
||||||
|
assert(
|
||||||
|
redirectUri,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.General, {
|
||||||
|
message: "CAN NOT find 'redirectUri' from connector session.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch token from the OIDC provider using authorization code
|
||||||
|
const { idToken } = await fetchToken(oidcConfig, data, redirectUri);
|
||||||
|
|
||||||
|
// Decode and verify the id token
|
||||||
|
const { sub, name, picture, email, email_verified, phone, phone_verified } =
|
||||||
|
await getIdTokenClaims(idToken, oidcConfig, nonce);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sub,
|
||||||
|
name: conditional(name),
|
||||||
|
avatar: conditional(picture),
|
||||||
|
email: conditional(email_verified && email),
|
||||||
|
phone: conditional(phone_verified && phone),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OidcConnector;
|
191
packages/core/src/sso/OidcConnector/utils.test.ts
Normal file
191
packages/core/src/sso/OidcConnector/utils.test.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
|
import {
|
||||||
|
oidcConfigResponseGuard,
|
||||||
|
oidcAuthorizationResponseGuard,
|
||||||
|
oidcTokenResponseGuard,
|
||||||
|
} from '../types/oidc.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
const { mockEsm } = createMockUtils(jest);
|
||||||
|
const getMock = jest.fn();
|
||||||
|
const postMock = jest.fn();
|
||||||
|
|
||||||
|
class MockHttpError {
|
||||||
|
constructor(public response: { body: unknown }) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockEsm('got', () => ({
|
||||||
|
got: {
|
||||||
|
get: getMock,
|
||||||
|
post: postMock,
|
||||||
|
},
|
||||||
|
HTTPError: MockHttpError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { fetchOidcConfig, fetchToken } = await import('./utils.js');
|
||||||
|
|
||||||
|
const issuer = 'https://example.com';
|
||||||
|
const oidcConfig = {
|
||||||
|
clientId: 'clientId',
|
||||||
|
clientSecret: 'clientSecret',
|
||||||
|
scope: 'openid',
|
||||||
|
issuer,
|
||||||
|
};
|
||||||
|
const oidcConfigResponse = {
|
||||||
|
token_endpoint: 'https://example.com/token',
|
||||||
|
authorization_endpoint: 'https://example.com/authorize',
|
||||||
|
userinfo_endpoint: 'https://example.com/userinfo',
|
||||||
|
jwks_uri: 'https://example.com/jwks',
|
||||||
|
issuer,
|
||||||
|
};
|
||||||
|
const oidcConfigResponseCamelCase = camelcaseKeys(oidcConfigResponse);
|
||||||
|
|
||||||
|
describe('fetchOidcConfig', () => {
|
||||||
|
it('should throw connector error if the discovery endpoint is not found', async () => {
|
||||||
|
getMock.mockRejectedValueOnce(new MockHttpError({ body: 'invalid endpoint' }));
|
||||||
|
|
||||||
|
await expect(fetchOidcConfig(issuer)).rejects.toMatchError(
|
||||||
|
new ConnectorError(ConnectorErrorCodes.General, 'invalid endpoint')
|
||||||
|
);
|
||||||
|
expect(getMock).toBeCalledWith(`${issuer}/.well-known/openid-configuration`, {
|
||||||
|
responseType: 'json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw connector error if the discovery endpoint returns invalid config', async () => {
|
||||||
|
const body = {
|
||||||
|
token_endpoint: 'https://example.com/token',
|
||||||
|
};
|
||||||
|
|
||||||
|
getMock.mockResolvedValueOnce({
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = oidcConfigResponseGuard.safeParse(body);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
throw new Error('invalid test case');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(fetchOidcConfig(issuer)).rejects.toMatchError(
|
||||||
|
new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the config if the discovery endpoint returns valid config', async () => {
|
||||||
|
getMock.mockResolvedValueOnce({
|
||||||
|
body: oidcConfigResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchOidcConfig(issuer)).resolves.toEqual(oidcConfigResponseCamelCase);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchToken', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUri = 'https://example.com/callback';
|
||||||
|
const data = {
|
||||||
|
code: 'code',
|
||||||
|
state: 'state',
|
||||||
|
};
|
||||||
|
const tokenResponse = {
|
||||||
|
id_token: 'id_token',
|
||||||
|
access_token: 'access_token',
|
||||||
|
expires_in: 3600,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should throw connector error if the authorization response data is not valid', async () => {
|
||||||
|
const data = {};
|
||||||
|
const result = oidcAuthorizationResponseGuard.safeParse(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
throw new Error('invalid test case');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchToken(
|
||||||
|
{
|
||||||
|
...oidcConfig,
|
||||||
|
...oidcConfigResponseCamelCase,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
redirectUri
|
||||||
|
)
|
||||||
|
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General, result.error));
|
||||||
|
|
||||||
|
expect(postMock).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw connector error if the token endpoint throws HTTPError', async () => {
|
||||||
|
postMock.mockRejectedValueOnce(new MockHttpError({ body: 'invalid response' }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchToken(
|
||||||
|
{
|
||||||
|
...oidcConfig,
|
||||||
|
...oidcConfigResponseCamelCase,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
redirectUri
|
||||||
|
)
|
||||||
|
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General, 'invalid response'));
|
||||||
|
|
||||||
|
expect(postMock).toBeCalledWith({
|
||||||
|
url: oidcConfigResponseCamelCase.tokenEndpoint,
|
||||||
|
form: {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: oidcConfig.clientId,
|
||||||
|
client_secret: oidcConfig.clientSecret,
|
||||||
|
code: data.code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw connector error if the token endpoint does not return id_token', async () => {
|
||||||
|
const body = { refresh_token: 'refresh_token' };
|
||||||
|
const result = oidcTokenResponseGuard.safeParse(body);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
throw new Error('invalid test case');
|
||||||
|
}
|
||||||
|
|
||||||
|
postMock.mockResolvedValueOnce({
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchToken(
|
||||||
|
{
|
||||||
|
...oidcConfig,
|
||||||
|
...oidcConfigResponseCamelCase,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
redirectUri
|
||||||
|
)
|
||||||
|
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the token response if the token endpoint returns valid response', async () => {
|
||||||
|
postMock.mockResolvedValueOnce({
|
||||||
|
body: JSON.stringify(tokenResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchToken(
|
||||||
|
{
|
||||||
|
...oidcConfig,
|
||||||
|
...oidcConfigResponseCamelCase,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
redirectUri
|
||||||
|
)
|
||||||
|
).resolves.toEqual(camelcaseKeys(tokenResponse));
|
||||||
|
});
|
||||||
|
});
|
119
packages/core/src/sso/OidcConnector/utils.ts
Normal file
119
packages/core/src/sso/OidcConnector/utils.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit';
|
||||||
|
import { assert } from '@silverhand/essentials';
|
||||||
|
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
|
||||||
|
import { got, HTTPError } from 'got';
|
||||||
|
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BaseOidcConfig,
|
||||||
|
type OidcConfigResponse,
|
||||||
|
oidcConfigResponseGuard,
|
||||||
|
oidcAuthorizationResponseGuard,
|
||||||
|
oidcTokenResponseGuard,
|
||||||
|
type OidcTokenResponse,
|
||||||
|
idTokenProfileStandardClaimsGuard,
|
||||||
|
} from '../types/oidc.js';
|
||||||
|
|
||||||
|
export const fetchOidcConfig = async (
|
||||||
|
issuer: string
|
||||||
|
): Promise<CamelCaseKeys<OidcConfigResponse>> => {
|
||||||
|
try {
|
||||||
|
const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, {
|
||||||
|
responseType: 'json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = oidcConfigResponseGuard.safeParse(body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return camelcaseKeys(result.data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.General, error.response.body);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchToken = async (
|
||||||
|
{ tokenEndpoint, clientId, clientSecret }: BaseOidcConfig,
|
||||||
|
data: unknown,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<CamelCaseKeys<OidcTokenResponse>> => {
|
||||||
|
const result = oidcAuthorizationResponseGuard.safeParse(data);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.General, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = result.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const httpResponse = await got.post({
|
||||||
|
url: tokenEndpoint,
|
||||||
|
form: {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = oidcTokenResponseGuard.safeParse(parseJson(httpResponse.body));
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return camelcaseKeys(result.data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.General, error.response.body);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const issuedAtTimeTolerance = 60;
|
||||||
|
|
||||||
|
export const getIdTokenClaims = async (
|
||||||
|
idToken: string,
|
||||||
|
config: BaseOidcConfig,
|
||||||
|
nonceFromSession?: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(config.jwksUri)), {
|
||||||
|
issuer: config.issuer,
|
||||||
|
audience: config.clientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Math.abs((payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, 'id_token is expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = idTokenProfileStandardClaimsGuard.safeParse(payload);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = result;
|
||||||
|
|
||||||
|
if (data.nonce) {
|
||||||
|
assert(
|
||||||
|
data.nonce === nonceFromSession,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, 'nonce claim not match')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ConnectorError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, error);
|
||||||
|
}
|
||||||
|
};
|
30
packages/core/src/sso/OidcSsoConnector/index.test.ts
Normal file
30
packages/core/src/sso/OidcSsoConnector/index.test.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||||
|
|
||||||
|
import { mockSsoConnector } from '#src/__mocks__/sso.js';
|
||||||
|
|
||||||
|
import { SsoProviderName } from '../types/index.js';
|
||||||
|
|
||||||
|
import { oidcSsoConnectorFactory } from './index.js';
|
||||||
|
|
||||||
|
describe('OidcSsoConnector', () => {
|
||||||
|
it('OidcSsoConnector should contains static properties', () => {
|
||||||
|
expect(oidcSsoConnectorFactory.providerName).toEqual(SsoProviderName.OIDC);
|
||||||
|
expect(oidcSsoConnectorFactory.configGuard).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructor should throw error if config is invalid', () => {
|
||||||
|
const result = oidcSsoConnectorFactory.configGuard.safeParse(mockSsoConnector.config);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
throw new Error('Invalid config');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createOidcSsoConnector = () => {
|
||||||
|
return new oidcSsoConnectorFactory.constructor(mockSsoConnector);
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(createOidcSsoConnector).toThrow(
|
||||||
|
new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
35
packages/core/src/sso/OidcSsoConnector/index.ts
Normal file
35
packages/core/src/sso/OidcSsoConnector/index.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||||
|
import { type SsoConnector } from '@logto/schemas';
|
||||||
|
|
||||||
|
import OidcConnector from '../OidcConnector/index.js';
|
||||||
|
import { type SingleSignOnFactory } from '../index.js';
|
||||||
|
import { type SingleSignOn, SsoProviderName } from '../types/index.js';
|
||||||
|
import { basicOidcConnectorConfigGuard } from '../types/oidc.js';
|
||||||
|
|
||||||
|
export class OidcSsoConnector extends OidcConnector implements SingleSignOn {
|
||||||
|
constructor(private readonly _data: SsoConnector) {
|
||||||
|
const parseConfigResult = basicOidcConnectorConfigGuard.safeParse(_data.config);
|
||||||
|
|
||||||
|
if (!parseConfigResult.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
super(parseConfigResult.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
get data() {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig = async () => this.getOidcConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oidcSsoConnectorFactory: SingleSignOnFactory<SsoProviderName.OIDC> = {
|
||||||
|
providerName: SsoProviderName.OIDC,
|
||||||
|
logo: 'oidc.svg',
|
||||||
|
description: {
|
||||||
|
en: ' This connector is used to connect with OIDC single sign-on identity provider.',
|
||||||
|
},
|
||||||
|
configGuard: basicOidcConnectorConfigGuard,
|
||||||
|
constructor: OidcSsoConnector,
|
||||||
|
};
|
27
packages/core/src/sso/index.ts
Normal file
27
packages/core/src/sso/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { type I18nPhrases } from '@logto/connector-kit';
|
||||||
|
|
||||||
|
import { oidcSsoConnectorFactory, type OidcSsoConnector } from './OidcSsoConnector/index.js';
|
||||||
|
import { SsoProviderName } from './types/index.js';
|
||||||
|
import { type basicOidcConnectorConfigGuard } from './types/oidc.js';
|
||||||
|
|
||||||
|
type SingleSignOnConstructor<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
||||||
|
? typeof OidcSsoConnector
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type SingleSignOnConnectorConfig<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
||||||
|
? typeof basicOidcConnectorConfigGuard
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type SingleSignOnFactory<T extends SsoProviderName> = {
|
||||||
|
providerName: T;
|
||||||
|
logo: string;
|
||||||
|
description: I18nPhrases;
|
||||||
|
configGuard: SingleSignOnConnectorConfig<T>;
|
||||||
|
constructor: SingleSignOnConstructor<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssoConnectorFactories: {
|
||||||
|
[key in SsoProviderName]: SingleSignOnFactory<key>;
|
||||||
|
} = {
|
||||||
|
[SsoProviderName.OIDC]: oidcSsoConnectorFactory,
|
||||||
|
};
|
17
packages/core/src/sso/types/index.ts
Normal file
17
packages/core/src/sso/types/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { type JsonObject, type SsoConnector } from '@logto/schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single sign-on connector interface
|
||||||
|
* @interface SingleSignOn
|
||||||
|
*
|
||||||
|
* @property {SsoConnector} data - SSO connector data schema
|
||||||
|
* @method {getConfig} getConfig - Get the full-list of SSO config from the SSO provider
|
||||||
|
*/
|
||||||
|
export abstract class SingleSignOn {
|
||||||
|
abstract data: SsoConnector;
|
||||||
|
abstract getConfig: () => Promise<JsonObject>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SsoProviderName {
|
||||||
|
OIDC = 'OIDC',
|
||||||
|
}
|
15
packages/core/src/sso/types/oidc.test.ts
Normal file
15
packages/core/src/sso/types/oidc.test.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { scopePostProcessor } from './oidc.js';
|
||||||
|
|
||||||
|
describe('scopePostProcessor', () => {
|
||||||
|
it('`openid` will be added if not exists (with empty string)', () => {
|
||||||
|
expect(scopePostProcessor('')).toEqual('openid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`openid` will be added if not exists (with non-empty string)', () => {
|
||||||
|
expect(scopePostProcessor('profile')).toEqual('profile openid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('return original input if openid exists', () => {
|
||||||
|
expect(scopePostProcessor('profile openid')).toEqual('profile openid');
|
||||||
|
});
|
||||||
|
});
|
76
packages/core/src/sso/types/oidc.ts
Normal file
76
packages/core/src/sso/types/oidc.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import { type CamelCaseKeys } from 'camelcase-keys';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const openidScope = 'openid' as const;
|
||||||
|
const scopeDelimiter = /[ +]/;
|
||||||
|
/**
|
||||||
|
* Scope config processor for OIDC connector. openid scope is required to retrieve id_token
|
||||||
|
* @see https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
|
||||||
|
* @param {string} scope
|
||||||
|
* @returns {string}
|
||||||
|
*
|
||||||
|
* @remark Forked from @logto/oidc-connector
|
||||||
|
*/
|
||||||
|
export const scopePostProcessor = (scope = '') => {
|
||||||
|
const splitScopes = scope.split(scopeDelimiter).filter(Boolean);
|
||||||
|
|
||||||
|
if (!splitScopes.includes(openidScope)) {
|
||||||
|
return [...splitScopes, openidScope].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const basicOidcConnectorConfigGuard = z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
clientSecret: z.string(),
|
||||||
|
issuer: z.string(),
|
||||||
|
scope: z.string().optional().transform(scopePostProcessor),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BasicOidcConnectorConfig = z.infer<typeof basicOidcConnectorConfigGuard>;
|
||||||
|
|
||||||
|
export const oidcConfigResponseGuard = z.object({
|
||||||
|
authorization_endpoint: z.string(),
|
||||||
|
token_endpoint: z.string(),
|
||||||
|
userinfo_endpoint: z.string(),
|
||||||
|
jwks_uri: z.string(),
|
||||||
|
issuer: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OidcConfigResponse = z.infer<typeof oidcConfigResponseGuard>;
|
||||||
|
|
||||||
|
export type BaseOidcConfig = CamelCaseKeys<OidcConfigResponse> & {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const oidcAuthorizationResponseGuard = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
state: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oidcTokenResponseGuard = z.object({
|
||||||
|
id_token: z.string(),
|
||||||
|
access_token: z.string().optional(),
|
||||||
|
token_type: z.string().optional(),
|
||||||
|
expires_in: z.number().optional(),
|
||||||
|
refresh_token: z.string().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OidcTokenResponse = z.infer<typeof oidcTokenResponseGuard>;
|
||||||
|
|
||||||
|
// See https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims.
|
||||||
|
export const idTokenProfileStandardClaimsGuard = z.object({
|
||||||
|
sub: z.string(),
|
||||||
|
name: z.string().nullish(),
|
||||||
|
email: z.string().nullish(),
|
||||||
|
email_verified: z.boolean().nullish(),
|
||||||
|
phone: z.string().nullish(),
|
||||||
|
phone_verified: z.boolean().nullish(),
|
||||||
|
picture: z.string().nullish(),
|
||||||
|
profile: z.string().nullish(),
|
||||||
|
nonce: z.string().nullish(),
|
||||||
|
});
|
|
@ -18,6 +18,7 @@ import { createRolesScopesQueries } from '#src/queries/roles-scopes.js';
|
||||||
import { createRolesQueries } from '#src/queries/roles.js';
|
import { createRolesQueries } from '#src/queries/roles.js';
|
||||||
import { createScopeQueries } from '#src/queries/scope.js';
|
import { createScopeQueries } from '#src/queries/scope.js';
|
||||||
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
|
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
|
||||||
|
import SsoConnectorQueries from '#src/queries/sso-connectors.js';
|
||||||
import { createUserQueries } from '#src/queries/user.js';
|
import { createUserQueries } from '#src/queries/user.js';
|
||||||
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||||
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
||||||
|
@ -43,6 +44,7 @@ export default class Queries {
|
||||||
domains = createDomainsQueries(this.pool);
|
domains = createDomainsQueries(this.pool);
|
||||||
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
||||||
organizations = new OrganizationQueries(this.pool);
|
organizations = new OrganizationQueries(this.pool);
|
||||||
|
ssoConnectors = new SsoConnectorQueries(this.pool);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly pool: CommonQueryMethods,
|
public readonly pool: CommonQueryMethods,
|
||||||
|
|
|
@ -25,8 +25,8 @@ export default class SchemaQueries<
|
||||||
) => Promise<{ count: number }>;
|
) => Promise<{ count: number }>;
|
||||||
|
|
||||||
#findAll: <SearchKey extends Key>(
|
#findAll: <SearchKey extends Key>(
|
||||||
limit: number,
|
limit?: number,
|
||||||
offset: number,
|
offset?: number,
|
||||||
search?: SearchOptions<SearchKey>
|
search?: SearchOptions<SearchKey>
|
||||||
) => Promise<readonly Schema[]>;
|
) => Promise<readonly Schema[]>;
|
||||||
|
|
||||||
|
@ -53,8 +53,8 @@ export default class SchemaQueries<
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll<SearchKey extends Key>(
|
async findAll<SearchKey extends Key>(
|
||||||
limit: number,
|
limit?: number,
|
||||||
offset: number,
|
offset?: number,
|
||||||
search?: SearchOptions<SearchKey>
|
search?: SearchOptions<SearchKey>
|
||||||
): Promise<[totalNumber: number, rows: readonly Schema[]]> {
|
): Promise<[totalNumber: number, rows: readonly Schema[]]> {
|
||||||
return Promise.all([this.findTotalNumber(search), this.#findAll(limit, offset, search)]);
|
return Promise.all([this.findTotalNumber(search), this.#findAll(limit, offset, search)]);
|
||||||
|
|
|
@ -21,7 +21,7 @@ import type SchemaQueries from './SchemaQueries.js';
|
||||||
* tableToPathname('organization_role') // => 'organization-role'
|
* tableToPathname('organization_role') // => 'organization-role'
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
const tableToPathname = (tableName: string) => tableName.replaceAll('_', '-');
|
export const tableToPathname = (tableName: string) => tableName.replaceAll('_', '-');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the camel case schema ID column name.
|
* Generate the camel case schema ID column name.
|
||||||
|
|
52
packages/integration-tests/src/api/sso-connector.ts
Normal file
52
packages/integration-tests/src/api/sso-connector.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { type CreateSsoConnector, type SsoConnector } from '@logto/schemas';
|
||||||
|
|
||||||
|
import { authedAdminApi } from '#src/api/api.js';
|
||||||
|
|
||||||
|
export type ConnectorFactoryDetail = {
|
||||||
|
providerName: string;
|
||||||
|
logo: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectorFactoryResponse = {
|
||||||
|
standardConnectors: ConnectorFactoryDetail[];
|
||||||
|
providerConnectors: ConnectorFactoryDetail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SsoConnectorWithProviderConfig = SsoConnector & {
|
||||||
|
providerLogo: string;
|
||||||
|
providerConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSsoConnectorFactories = async () =>
|
||||||
|
authedAdminApi.get('sso-connector-factories').json<ConnectorFactoryResponse>();
|
||||||
|
|
||||||
|
export const createSsoConnector = async (data: Partial<CreateSsoConnector>) =>
|
||||||
|
authedAdminApi
|
||||||
|
.post('sso-connectors', {
|
||||||
|
json: data,
|
||||||
|
})
|
||||||
|
.json<SsoConnector>();
|
||||||
|
|
||||||
|
export const getSsoConnectors = async () =>
|
||||||
|
authedAdminApi.get('sso-connectors').json<SsoConnectorWithProviderConfig[]>();
|
||||||
|
|
||||||
|
export const getSsoConnectorById = async (id: string) =>
|
||||||
|
authedAdminApi.get(`sso-connectors/${id}`).json<SsoConnectorWithProviderConfig>();
|
||||||
|
|
||||||
|
export const deleteSsoConnectorById = async (id: string) =>
|
||||||
|
authedAdminApi.delete(`sso-connectors/${id}`).json<void>();
|
||||||
|
|
||||||
|
export const patchSsoConnectorById = async (id: string, data: Partial<SsoConnector>) =>
|
||||||
|
authedAdminApi
|
||||||
|
.patch(`sso-connectors/${id}`, {
|
||||||
|
json: data,
|
||||||
|
})
|
||||||
|
.json<SsoConnectorWithProviderConfig>();
|
||||||
|
|
||||||
|
export const patchSsoConnectorConfigById = async (id: string, data: Record<string, unknown>) =>
|
||||||
|
authedAdminApi
|
||||||
|
.patch(`sso-connectors/${id}/config`, {
|
||||||
|
json: data,
|
||||||
|
})
|
||||||
|
.json<SsoConnectorWithProviderConfig>();
|
286
packages/integration-tests/src/tests/api/sso-connectors.test.ts
Normal file
286
packages/integration-tests/src/tests/api/sso-connectors.test.ts
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
import { HTTPError } from 'got';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSsoConnectorFactories,
|
||||||
|
createSsoConnector,
|
||||||
|
getSsoConnectors,
|
||||||
|
getSsoConnectorById,
|
||||||
|
deleteSsoConnectorById,
|
||||||
|
patchSsoConnectorById,
|
||||||
|
patchSsoConnectorConfigById,
|
||||||
|
} from '#src/api/sso-connector.js';
|
||||||
|
|
||||||
|
describe('sso-connector library', () => {
|
||||||
|
it('should return sso-connector-factories', async () => {
|
||||||
|
const response = await getSsoConnectorFactories();
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('standardConnectors');
|
||||||
|
expect(response).toHaveProperty('providerConnectors');
|
||||||
|
|
||||||
|
expect(response.standardConnectors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('post sso-connectors', () => {
|
||||||
|
it('should throw error when providerName is not provided', async () => {
|
||||||
|
await expect(
|
||||||
|
createSsoConnector({
|
||||||
|
connectorName: 'test',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when connectorName is not provided', async () => {
|
||||||
|
await expect(
|
||||||
|
createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when providerName is not supported', async () => {
|
||||||
|
await expect(
|
||||||
|
createSsoConnector({
|
||||||
|
providerName: 'dummy provider',
|
||||||
|
connectorName: 'test',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new sso connector', async () => {
|
||||||
|
const response = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('id');
|
||||||
|
expect(response).toHaveProperty('providerName', 'OIDC');
|
||||||
|
expect(response).toHaveProperty('connectorName', 'test');
|
||||||
|
expect(response).toHaveProperty('config', {});
|
||||||
|
expect(response).toHaveProperty('domains', []);
|
||||||
|
expect(response).toHaveProperty('ssoOnly', false);
|
||||||
|
expect(response).toHaveProperty('syncProfile', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if invalid config is provided', async () => {
|
||||||
|
await expect(
|
||||||
|
createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'test',
|
||||||
|
config: {
|
||||||
|
issuer: 23,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get sso-connectors', () => {
|
||||||
|
it('should return sso connectors', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectors = await getSsoConnectors();
|
||||||
|
expect(connectors.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const connector = connectors.find((connector) => connector.id === id);
|
||||||
|
|
||||||
|
expect(connector).toBeDefined();
|
||||||
|
expect(connector?.providerLogo).toBeDefined();
|
||||||
|
|
||||||
|
// Invalid config
|
||||||
|
expect(connector?.providerConfig).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get sso-connector by id', () => {
|
||||||
|
it('should return 404 if connector is not found', async () => {
|
||||||
|
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return sso connector', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connector = await getSsoConnectorById(id);
|
||||||
|
|
||||||
|
expect(connector).toHaveProperty('id', id);
|
||||||
|
expect(connector).toHaveProperty('providerName', 'OIDC');
|
||||||
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
||||||
|
expect(connector).toHaveProperty('config', {});
|
||||||
|
expect(connector).toHaveProperty('domains', []);
|
||||||
|
expect(connector).toHaveProperty('ssoOnly', false);
|
||||||
|
expect(connector).toHaveProperty('syncProfile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete sso-connector by id', () => {
|
||||||
|
it('should return 404 if connector is not found', async () => {
|
||||||
|
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete sso connector', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getSsoConnectorById(id)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await deleteSsoConnectorById(id);
|
||||||
|
|
||||||
|
await expect(getSsoConnectorById(id)).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patch sso-connector by id', () => {
|
||||||
|
it('should return 404 if connector is not found', async () => {
|
||||||
|
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should patch sso connector without config', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connector = await patchSsoConnectorById(id, {
|
||||||
|
connectorName: 'integration_test connector updated',
|
||||||
|
domains: ['test.com'],
|
||||||
|
ssoOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connector).toHaveProperty('id', id);
|
||||||
|
expect(connector).toHaveProperty('providerName', 'OIDC');
|
||||||
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector updated');
|
||||||
|
expect(connector).toHaveProperty('config', {});
|
||||||
|
expect(connector).toHaveProperty('domains', ['test.com']);
|
||||||
|
expect(connector).toHaveProperty('ssoOnly', true);
|
||||||
|
expect(connector).toHaveProperty('syncProfile', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should directly return if no changes are made', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connector = await patchSsoConnectorById(id, {
|
||||||
|
config: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connector).toHaveProperty('id', id);
|
||||||
|
expect(connector).toHaveProperty('providerName', 'OIDC');
|
||||||
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
||||||
|
expect(connector).toHaveProperty('config', {});
|
||||||
|
expect(connector).toHaveProperty('domains', []);
|
||||||
|
expect(connector).toHaveProperty('ssoOnly', false);
|
||||||
|
expect(connector).toHaveProperty('syncProfile', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if invalid config is provided', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchSsoConnectorById(id, {
|
||||||
|
config: {
|
||||||
|
issuer: 23,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should patch sso connector with config', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connector = await patchSsoConnectorById(id, {
|
||||||
|
connectorName: 'integration_test connector updated',
|
||||||
|
config: {
|
||||||
|
clientId: 'foo',
|
||||||
|
issuer: 'https://test.com',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
issuer: 'https://test.com',
|
||||||
|
});
|
||||||
|
expect(connector).toHaveProperty('syncProfile', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patch sso-connector config by id', () => {
|
||||||
|
it('should return 404 if connector is not found', async () => {
|
||||||
|
await expect(patchSsoConnectorConfigById('invalid-id', {})).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if invalid config is provided', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchSsoConnectorConfigById(id, {
|
||||||
|
issuer: 23,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(HTTPError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should patch sso connector config', async () => {
|
||||||
|
const { id } = await createSsoConnector({
|
||||||
|
providerName: 'OIDC',
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connector = await patchSsoConnectorConfigById(id, {
|
||||||
|
clientId: 'foo',
|
||||||
|
issuer: 'https://test.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connector).toHaveProperty('id', id);
|
||||||
|
expect(connector).toHaveProperty('providerName', 'OIDC');
|
||||||
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
||||||
|
expect(connector).toHaveProperty('config', {
|
||||||
|
clientId: 'foo',
|
||||||
|
issuer: 'https://test.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -30,7 +30,7 @@ export const i18nPhrasesGuard: ZodType<I18nPhrases> = z
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
type I18nPhrases = { en: string } & {
|
export type I18nPhrases = { en: string } & {
|
||||||
[K in Exclude<LanguageTag, 'en'>]?: string;
|
[K in Exclude<LanguageTag, 'en'>]?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3181,6 +3181,9 @@ importers:
|
||||||
camelcase:
|
camelcase:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0
|
version: 8.0.0
|
||||||
|
camelcase-keys:
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
chalk:
|
chalk:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.1.2
|
version: 5.1.2
|
||||||
|
@ -10947,7 +10950,6 @@ packages:
|
||||||
map-obj: 5.0.0
|
map-obj: 5.0.0
|
||||||
quick-lru: 6.1.1
|
quick-lru: 6.1.1
|
||||||
type-fest: 4.2.0
|
type-fest: 4.2.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/camelcase@5.3.1:
|
/camelcase@5.3.1:
|
||||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
@ -15738,7 +15740,6 @@ packages:
|
||||||
/map-obj@5.0.0:
|
/map-obj@5.0.0:
|
||||||
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
|
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/markdown-escapes@1.0.4:
|
/markdown-escapes@1.0.4:
|
||||||
resolution: {integrity: sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==}
|
resolution: {integrity: sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==}
|
||||||
|
@ -20115,7 +20116,6 @@ packages:
|
||||||
/type-fest@4.2.0:
|
/type-fest@4.2.0:
|
||||||
resolution: {integrity: sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==}
|
resolution: {integrity: sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/type-is@1.6.18:
|
/type-is@1.6.18:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
|
|
Loading…
Reference in a new issue