0
Fork 0
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:
simeng-li 2023-10-25 14:44:58 +08:00 committed by GitHub
parent 233ccff0fa
commit 2b15b13bbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1540 additions and 9 deletions

View file

@ -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",

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

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

View file

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

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

View 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();

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

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

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

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

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

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

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

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

View 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',
}

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

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

View file

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

View file

@ -25,8 +25,8 @@ export default class SchemaQueries<
) => Promise<{ count: number }>;
#findAll: <SearchKey extends Key>(
limit: number,
offset: number,
limit?: number,
offset?: number,
search?: SearchOptions<SearchKey>
) => Promise<readonly Schema[]>;
@ -53,8 +53,8 @@ export default class SchemaQueries<
}
async findAll<SearchKey extends Key>(
limit: number,
offset: number,
limit?: number,
offset?: number,
search?: SearchOptions<SearchKey>
): Promise<[totalNumber: number, rows: readonly Schema[]]> {
return Promise.all([this.findTotalNumber(search), this.#findAll(limit, offset, search)]);

View file

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

View 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>();

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

View file

@ -30,7 +30,7 @@ export const i18nPhrasesGuard: ZodType<I18nPhrases> = z
return true;
});
type I18nPhrases = { en: string } & {
export type I18nPhrases = { en: string } & {
[K in Exclude<LanguageTag, 'en'>]?: string;
};

View file

@ -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==}