From 2b15b13bbfc7ec38e92f5a16eebeef7fe3e1a0c7 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 25 Oct 2023 14:44:58 +0800 Subject: [PATCH] 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 --- packages/core/package.json | 1 + packages/core/src/__mocks__/sso.ts | 14 + packages/core/src/queries/sso-connectors.ts | 19 ++ packages/core/src/routes/init.ts | 2 + .../core/src/routes/sso-connector/index.ts | 280 +++++++++++++++++ .../core/src/routes/sso-connector/type.ts | 48 +++ .../src/routes/sso-connector/utils.test.ts | 112 +++++++ .../core/src/routes/sso-connector/utils.ts | 80 +++++ packages/core/src/sso/OidcConnector/index.ts | 125 ++++++++ .../core/src/sso/OidcConnector/utils.test.ts | 191 ++++++++++++ packages/core/src/sso/OidcConnector/utils.ts | 119 ++++++++ .../src/sso/OidcSsoConnector/index.test.ts | 30 ++ .../core/src/sso/OidcSsoConnector/index.ts | 35 +++ packages/core/src/sso/index.ts | 27 ++ packages/core/src/sso/types/index.ts | 17 ++ packages/core/src/sso/types/oidc.test.ts | 15 + packages/core/src/sso/types/oidc.ts | 76 +++++ packages/core/src/tenants/Queries.ts | 2 + packages/core/src/utils/SchemaQueries.ts | 8 +- packages/core/src/utils/SchemaRouter.ts | 2 +- .../src/api/sso-connector.ts | 52 ++++ .../src/tests/api/sso-connectors.test.ts | 286 ++++++++++++++++++ .../connector-kit/src/types/metadata.ts | 2 +- pnpm-lock.yaml | 6 +- 24 files changed, 1540 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/__mocks__/sso.ts create mode 100644 packages/core/src/queries/sso-connectors.ts create mode 100644 packages/core/src/routes/sso-connector/index.ts create mode 100644 packages/core/src/routes/sso-connector/type.ts create mode 100644 packages/core/src/routes/sso-connector/utils.test.ts create mode 100644 packages/core/src/routes/sso-connector/utils.ts create mode 100644 packages/core/src/sso/OidcConnector/index.ts create mode 100644 packages/core/src/sso/OidcConnector/utils.test.ts create mode 100644 packages/core/src/sso/OidcConnector/utils.ts create mode 100644 packages/core/src/sso/OidcSsoConnector/index.test.ts create mode 100644 packages/core/src/sso/OidcSsoConnector/index.ts create mode 100644 packages/core/src/sso/index.ts create mode 100644 packages/core/src/sso/types/index.ts create mode 100644 packages/core/src/sso/types/oidc.test.ts create mode 100644 packages/core/src/sso/types/oidc.ts create mode 100644 packages/integration-tests/src/api/sso-connector.ts create mode 100644 packages/integration-tests/src/tests/api/sso-connectors.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 05c37c1de..d8e49ab90 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,7 @@ "@simplewebauthn/server": "^8.2.0", "@withtyped/client": "^0.7.22", "camelcase": "^8.0.0", + "camelcase-keys": "^9.0.0", "chalk": "^5.0.0", "clean-deep": "^3.4.0", "date-fns": "^2.29.3", diff --git a/packages/core/src/__mocks__/sso.ts b/packages/core/src/__mocks__/sso.ts new file mode 100644 index 000000000..1bf4f0488 --- /dev/null +++ b/packages/core/src/__mocks__/sso.ts @@ -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(), +}; diff --git a/packages/core/src/queries/sso-connectors.ts b/packages/core/src/queries/sso-connectors.ts new file mode 100644 index 000000000..9e968fbb6 --- /dev/null +++ b/packages/core/src/queries/sso-connectors.ts @@ -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); + } +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 5aa6ea3c4..ff832b813 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -27,6 +27,7 @@ import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; +import ssoConnectors from './sso-connector/index.js'; import statusRoutes from './status.js'; import swaggerRoutes from './swagger.js'; import type { AnonymousRouter, AuthedRouter } from './types.js'; @@ -59,6 +60,7 @@ const createRouters = (tenant: TenantContext) => { userAssetsRoutes(managementRouter, tenant); domainRoutes(managementRouter, tenant); organizationRoutes(managementRouter, tenant); + ssoConnectors(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); wellKnownRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts new file mode 100644 index 000000000..a6a0c32b9 --- /dev/null +++ b/packages/core/src/routes/sso-connector/index.ts @@ -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(...args: RouterInitArgs) { + 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(); + const providerConnectors = new Set(); + + 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(); + } + ); +} diff --git a/packages/core/src/routes/sso-connector/type.ts b/packages/core/src/routes/sso-connector/type.ts new file mode 100644 index 000000000..ef4b8bc75 --- /dev/null +++ b/packages/core/src/routes/sso-connector/type.ts @@ -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; + +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; + +export const ssoConnectorPatchGuard = SsoConnectors.guard + .pick({ + config: true, + domains: true, + branding: true, + syncProfile: true, + ssoOnly: true, + connectorName: true, + }) + .partial(); diff --git a/packages/core/src/routes/sso-connector/utils.test.ts b/packages/core/src/routes/sso-connector/utils.test.ts new file mode 100644 index 000000000..d78e06b82 --- /dev/null +++ b/packages/core/src/routes/sso-connector/utils.test.ts @@ -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', + }, + }); + }); +}); diff --git a/packages/core/src/routes/sso-connector/utils.ts b/packages/core/src/routes/sso-connector/utils.ts new file mode 100644 index 000000000..3eebfe33e --- /dev/null +++ b/packages/core/src/routes/sso-connector/utils.ts @@ -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, + 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 => { + 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 }), + }; +}; diff --git a/packages/core/src/sso/OidcConnector/index.ts b/packages/core/src/sso/OidcConnector/index.ts new file mode 100644 index 000000000..39bed5162 --- /dev/null +++ b/packages/core/src/sso/OidcConnector/index.ts @@ -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 => { + 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; diff --git a/packages/core/src/sso/OidcConnector/utils.test.ts b/packages/core/src/sso/OidcConnector/utils.test.ts new file mode 100644 index 000000000..306236032 --- /dev/null +++ b/packages/core/src/sso/OidcConnector/utils.test.ts @@ -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)); + }); +}); diff --git a/packages/core/src/sso/OidcConnector/utils.ts b/packages/core/src/sso/OidcConnector/utils.ts new file mode 100644 index 000000000..19943f5f6 --- /dev/null +++ b/packages/core/src/sso/OidcConnector/utils.ts @@ -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> => { + 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> => { + 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); + } +}; diff --git a/packages/core/src/sso/OidcSsoConnector/index.test.ts b/packages/core/src/sso/OidcSsoConnector/index.test.ts new file mode 100644 index 000000000..107226aa5 --- /dev/null +++ b/packages/core/src/sso/OidcSsoConnector/index.test.ts @@ -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) + ); + }); +}); diff --git a/packages/core/src/sso/OidcSsoConnector/index.ts b/packages/core/src/sso/OidcSsoConnector/index.ts new file mode 100644 index 000000000..36ad3d330 --- /dev/null +++ b/packages/core/src/sso/OidcSsoConnector/index.ts @@ -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 = { + 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, +}; diff --git a/packages/core/src/sso/index.ts b/packages/core/src/sso/index.ts new file mode 100644 index 000000000..99bf3d786 --- /dev/null +++ b/packages/core/src/sso/index.ts @@ -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.OIDC + ? typeof OidcSsoConnector + : never; + +type SingleSignOnConnectorConfig = T extends SsoProviderName.OIDC + ? typeof basicOidcConnectorConfigGuard + : never; + +export type SingleSignOnFactory = { + providerName: T; + logo: string; + description: I18nPhrases; + configGuard: SingleSignOnConnectorConfig; + constructor: SingleSignOnConstructor; +}; + +export const ssoConnectorFactories: { + [key in SsoProviderName]: SingleSignOnFactory; +} = { + [SsoProviderName.OIDC]: oidcSsoConnectorFactory, +}; diff --git a/packages/core/src/sso/types/index.ts b/packages/core/src/sso/types/index.ts new file mode 100644 index 000000000..ac13c7bae --- /dev/null +++ b/packages/core/src/sso/types/index.ts @@ -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; +} + +export enum SsoProviderName { + OIDC = 'OIDC', +} diff --git a/packages/core/src/sso/types/oidc.test.ts b/packages/core/src/sso/types/oidc.test.ts new file mode 100644 index 000000000..4e11eef91 --- /dev/null +++ b/packages/core/src/sso/types/oidc.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/sso/types/oidc.ts b/packages/core/src/sso/types/oidc.ts new file mode 100644 index 000000000..ea719d272 --- /dev/null +++ b/packages/core/src/sso/types/oidc.ts @@ -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; + +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; + +export type BaseOidcConfig = CamelCaseKeys & { + 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; + +// 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(), +}); diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 577c4053c..e879ff142 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -18,6 +18,7 @@ import { createRolesScopesQueries } from '#src/queries/roles-scopes.js'; import { createRolesQueries } from '#src/queries/roles.js'; import { createScopeQueries } from '#src/queries/scope.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 { createUsersRolesQueries } from '#src/queries/users-roles.js'; import { createVerificationStatusQueries } from '#src/queries/verification-status.js'; @@ -43,6 +44,7 @@ export default class Queries { domains = createDomainsQueries(this.pool); dailyActiveUsers = createDailyActiveUsersQueries(this.pool); organizations = new OrganizationQueries(this.pool); + ssoConnectors = new SsoConnectorQueries(this.pool); constructor( public readonly pool: CommonQueryMethods, diff --git a/packages/core/src/utils/SchemaQueries.ts b/packages/core/src/utils/SchemaQueries.ts index 6322e2b39..f854b2f8e 100644 --- a/packages/core/src/utils/SchemaQueries.ts +++ b/packages/core/src/utils/SchemaQueries.ts @@ -25,8 +25,8 @@ export default class SchemaQueries< ) => Promise<{ count: number }>; #findAll: ( - limit: number, - offset: number, + limit?: number, + offset?: number, search?: SearchOptions ) => Promise; @@ -53,8 +53,8 @@ export default class SchemaQueries< } async findAll( - limit: number, - offset: number, + limit?: number, + offset?: number, search?: SearchOptions ): Promise<[totalNumber: number, rows: readonly Schema[]]> { return Promise.all([this.findTotalNumber(search), this.#findAll(limit, offset, search)]); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 2c7564ffe..84bd32c24 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -21,7 +21,7 @@ import type SchemaQueries from './SchemaQueries.js'; * 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. diff --git a/packages/integration-tests/src/api/sso-connector.ts b/packages/integration-tests/src/api/sso-connector.ts new file mode 100644 index 000000000..bdd16cb0e --- /dev/null +++ b/packages/integration-tests/src/api/sso-connector.ts @@ -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; +}; + +export const getSsoConnectorFactories = async () => + authedAdminApi.get('sso-connector-factories').json(); + +export const createSsoConnector = async (data: Partial) => + authedAdminApi + .post('sso-connectors', { + json: data, + }) + .json(); + +export const getSsoConnectors = async () => + authedAdminApi.get('sso-connectors').json(); + +export const getSsoConnectorById = async (id: string) => + authedAdminApi.get(`sso-connectors/${id}`).json(); + +export const deleteSsoConnectorById = async (id: string) => + authedAdminApi.delete(`sso-connectors/${id}`).json(); + +export const patchSsoConnectorById = async (id: string, data: Partial) => + authedAdminApi + .patch(`sso-connectors/${id}`, { + json: data, + }) + .json(); + +export const patchSsoConnectorConfigById = async (id: string, data: Record) => + authedAdminApi + .patch(`sso-connectors/${id}/config`, { + json: data, + }) + .json(); diff --git a/packages/integration-tests/src/tests/api/sso-connectors.test.ts b/packages/integration-tests/src/tests/api/sso-connectors.test.ts new file mode 100644 index 000000000..59956a52b --- /dev/null +++ b/packages/integration-tests/src/tests/api/sso-connectors.test.ts @@ -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', + }); + }); +}); diff --git a/packages/toolkit/connector-kit/src/types/metadata.ts b/packages/toolkit/connector-kit/src/types/metadata.ts index 478e4b758..7eddebf16 100644 --- a/packages/toolkit/connector-kit/src/types/metadata.ts +++ b/packages/toolkit/connector-kit/src/types/metadata.ts @@ -30,7 +30,7 @@ export const i18nPhrasesGuard: ZodType = z return true; }); -type I18nPhrases = { en: string } & { +export type I18nPhrases = { en: string } & { [K in Exclude]?: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c24e54187..801ef72a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3181,6 +3181,9 @@ importers: camelcase: specifier: ^8.0.0 version: 8.0.0 + camelcase-keys: + specifier: ^9.0.0 + version: 9.0.0 chalk: specifier: ^5.0.0 version: 5.1.2 @@ -10947,7 +10950,6 @@ packages: map-obj: 5.0.0 quick-lru: 6.1.1 type-fest: 4.2.0 - dev: true /camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} @@ -15738,7 +15740,6 @@ packages: /map-obj@5.0.0: resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dev: true /markdown-escapes@1.0.4: resolution: {integrity: sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==} @@ -20115,7 +20116,6 @@ packages: /type-fest@4.2.0: resolution: {integrity: sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==} engines: {node: '>=16'} - dev: true /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}