mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): add saml sso connector class
This commit is contained in:
parent
eaac1a5c60
commit
9f3433edc9
48 changed files with 947 additions and 149 deletions
|
@ -54,6 +54,7 @@
|
||||||
"deepmerge": "^4.2.2",
|
"deepmerge": "^4.2.2",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"etag": "^1.8.1",
|
"etag": "^1.8.1",
|
||||||
|
"fast-xml-parser": "^4.2.5",
|
||||||
"find-up": "^6.3.0",
|
"find-up": "^6.3.0",
|
||||||
"got": "^13.0.0",
|
"got": "^13.0.0",
|
||||||
"hash-wasm": "^4.9.0",
|
"hash-wasm": "^4.9.0",
|
||||||
|
@ -80,6 +81,7 @@
|
||||||
"redis": "^4.6.5",
|
"redis": "^4.6.5",
|
||||||
"roarr": "^7.11.0",
|
"roarr": "^7.11.0",
|
||||||
"semver": "^7.3.8",
|
"semver": "^7.3.8",
|
||||||
|
"samlify": "2.8.10",
|
||||||
"slonik": "^30.0.0",
|
"slonik": "^30.0.0",
|
||||||
"slonik-interceptor-preset": "^1.2.10",
|
"slonik-interceptor-preset": "^1.2.10",
|
||||||
"slonik-sql-tag-raw": "^1.1.4",
|
"slonik-sql-tag-raw": "^1.1.4",
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { type SsoConnector } from '@logto/schemas';
|
import { type SsoConnector } from '@logto/schemas';
|
||||||
|
|
||||||
import { SsoProviderName } from '#src/sso/types/index.js';
|
import { SsoProviderName } from '#src/sso/types/index.js';
|
||||||
|
import { type BaseOidcConfig } from '#src/sso/types/oidc.js';
|
||||||
|
import { MetadataType } from '#src/sso/types/saml.js';
|
||||||
|
import { type BaseSamlConfig } from '#src/sso/types/saml.js';
|
||||||
|
|
||||||
export const mockSsoConnector = {
|
export const mockSsoConnector = {
|
||||||
id: 'mock-sso-connector',
|
id: 'mock-sso-connector',
|
||||||
|
@ -31,3 +34,25 @@ export const wellConfiguredSsoConnector = {
|
||||||
ssoOnly: true,
|
ssoOnly: true,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
} satisfies SsoConnector;
|
} satisfies SsoConnector;
|
||||||
|
|
||||||
|
export const mockBaseSamlConfig = {
|
||||||
|
metadataType: MetadataType.XML,
|
||||||
|
metadataXml: 'metadataXml',
|
||||||
|
attributeMapping: {},
|
||||||
|
entityId: 'entityId',
|
||||||
|
nameIdFormat: ['nameIdFormat'],
|
||||||
|
signInEndpoint: 'signInEndpoint',
|
||||||
|
signingAlgorithm: 'signingAlgorithm',
|
||||||
|
x509Certificate: 'x509Certificate',
|
||||||
|
} satisfies BaseSamlConfig;
|
||||||
|
|
||||||
|
export const mockBaseOidcConfig = {
|
||||||
|
authorizationEndpoint: 'authorizationEndpoint',
|
||||||
|
tokenEndpoint: 'tokenEndpoint',
|
||||||
|
userinfoEndpoint: 'userinfoEndpoint',
|
||||||
|
jwksUri: 'jwksUri',
|
||||||
|
issuer: 'issuer',
|
||||||
|
clientId: 'clientId',
|
||||||
|
clientSecret: 'clientSecret',
|
||||||
|
scope: 'openid profile',
|
||||||
|
} satisfies BaseOidcConfig;
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { z } from 'zod';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js';
|
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.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 assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
import {
|
import {
|
||||||
getConnectorSessionResultFromJti,
|
getConnectorSessionResultFromJti,
|
||||||
|
@ -19,11 +21,12 @@ import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||||
* This router will have a route `/authn` to authenticate tokens with a general manner.
|
* This router will have a route `/authn` to authenticate tokens with a general manner.
|
||||||
*/
|
*/
|
||||||
export default function authnRoutes<T extends AnonymousRouter>(
|
export default function authnRoutes<T extends AnonymousRouter>(
|
||||||
...[router, { envSet, provider, libraries }]: RouterInitArgs<T>
|
...[router, { id: tenantId, envSet, provider, libraries }]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
users: { findUserRoles },
|
users: { findUserRoles },
|
||||||
socials: { getConnector },
|
socials: { getConnector },
|
||||||
|
ssoConnector: { getSsoConnectorById },
|
||||||
} = libraries;
|
} = libraries;
|
||||||
|
|
||||||
const hasuraResponseGuard = z.object({
|
const hasuraResponseGuard = z.object({
|
||||||
|
@ -144,4 +147,57 @@ export default function authnRoutes<T extends AnonymousRouter>(
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: refactor this, this SAML API for SSO is quite similar to the one for normal social sign-in, most of the logics can be reused.
|
||||||
|
router.post(
|
||||||
|
'/authn/saml/sso/:ssoConnectorId',
|
||||||
|
/**
|
||||||
|
* The API does not care the type of the SAML assertion request body, simply pass this to
|
||||||
|
* SSO connector's built-in methods.
|
||||||
|
*/
|
||||||
|
koaGuard({
|
||||||
|
body: jsonObjectGuard,
|
||||||
|
params: z.object({ ssoConnectorId: z.string().min(1) }),
|
||||||
|
status: 302,
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const {
|
||||||
|
params: { ssoConnectorId },
|
||||||
|
body,
|
||||||
|
} = ctx.guard;
|
||||||
|
const ssoConnector = await getSsoConnectorById(ssoConnectorId);
|
||||||
|
|
||||||
|
const samlAssertionGuard = z.object({ SAMLResponse: z.string(), RelayState: z.string() });
|
||||||
|
const samlAssertionParseResult = samlAssertionGuard.safeParse(body);
|
||||||
|
|
||||||
|
if (!samlAssertionParseResult.success) {
|
||||||
|
throw new ConnectorError(
|
||||||
|
ConnectorErrorCodes.InvalidResponse,
|
||||||
|
samlAssertionParseResult.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since `RelayState` will be returned with value unchanged, we use it to pass `jti`
|
||||||
|
* to find the connector session we used to store essential information.
|
||||||
|
*/
|
||||||
|
const { RelayState: jti } = samlAssertionParseResult.data;
|
||||||
|
|
||||||
|
const getSession = async () => getConnectorSessionResultFromJti(jti, provider);
|
||||||
|
const setSession = async (connectorSession: ConnectorSession) =>
|
||||||
|
assignConnectorSessionResultViaJti(jti, provider, connectorSession);
|
||||||
|
|
||||||
|
if (ssoConnector.providerName !== SsoProviderName.SAML) {
|
||||||
|
throw new RequestError({ code: 'sso_connector.saml_only' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { constructor } = ssoConnectorFactories[ssoConnector.providerName];
|
||||||
|
const { validateSamlAssertion } = new constructor(ssoConnector, tenantId);
|
||||||
|
const redirectTo = await validateSamlAssertion({ body }, getSession, setSession);
|
||||||
|
|
||||||
|
ctx.redirect(redirectTo);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
|
||||||
tenant: TenantContext
|
tenant: TenantContext
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
|
id: tenantId,
|
||||||
provider,
|
provider,
|
||||||
libraries: { ssoConnector },
|
libraries: { ssoConnector },
|
||||||
} = tenant;
|
} = tenant;
|
||||||
|
@ -43,7 +44,8 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { interactionDetails, guard, createLog } = ctx;
|
const { interactionDetails, guard, createLog, req, res } = ctx;
|
||||||
|
const { jti } = await provider.interactionDetails(req, res);
|
||||||
|
|
||||||
// Check interaction exists
|
// Check interaction exists
|
||||||
const { event } = getInteractionStorage(interactionDetails.result);
|
const { event } = getInteractionStorage(interactionDetails.result);
|
||||||
|
@ -75,12 +77,13 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
|
||||||
try {
|
try {
|
||||||
// Will throw ConnectorError if the config is invalid
|
// Will throw ConnectorError if the config is invalid
|
||||||
const connectorInstance = new ssoConnectorFactories[connectorData.providerName].constructor(
|
const connectorInstance = new ssoConnectorFactories[connectorData.providerName].constructor(
|
||||||
connectorData
|
connectorData,
|
||||||
|
tenantId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Will throw ConnectorError if failed to fetch the provider's config
|
// Will throw ConnectorError if failed to fetch the provider's config
|
||||||
const redirectTo = await connectorInstance.getAuthorizationUrl(
|
const redirectTo = await connectorInstance.getAuthorizationUrl(
|
||||||
{ state, redirectUri },
|
{ state, redirectUri, jti },
|
||||||
async (connectorSession: ConnectorSession) =>
|
async (connectorSession: ConnectorSession) =>
|
||||||
assignConnectorSessionResult(ctx, provider, connectorSession)
|
assignConnectorSessionResult(ctx, provider, connectorSession)
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,19 +5,20 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import { ssoConnectorFactories, standardSsoConnectorProviders } from '#src/sso/index.js';
|
|
||||||
import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js';
|
|
||||||
import { tableToPathname } from '#src/utils/SchemaRouter.js';
|
|
||||||
|
|
||||||
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
connectorFactoriesResponseGuard,
|
connectorFactoriesResponseGuard,
|
||||||
type ConnectorFactoryDetail,
|
type ConnectorFactoryDetail,
|
||||||
ssoConnectorCreateGuard,
|
ssoConnectorCreateGuard,
|
||||||
ssoConnectorWithProviderConfigGuard,
|
ssoConnectorWithProviderConfigGuard,
|
||||||
ssoConnectorPatchGuard,
|
ssoConnectorPatchGuard,
|
||||||
} from './type.js';
|
} from '#src/routes/sso-connector/type.js';
|
||||||
|
import { ssoConnectorFactories, standardSsoConnectorProviders } from '#src/sso/index.js';
|
||||||
|
import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js';
|
||||||
|
import { tableToPathname } from '#src/utils/SchemaRouter.js';
|
||||||
|
import { consoleLog } from '#src/utils/console.js';
|
||||||
|
|
||||||
|
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseFactoryDetail,
|
parseFactoryDetail,
|
||||||
parseConnectorConfig,
|
parseConnectorConfig,
|
||||||
|
@ -28,13 +29,15 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...args: Rout
|
||||||
const [
|
const [
|
||||||
router,
|
router,
|
||||||
{
|
{
|
||||||
libraries: { ssoConnector: ssoConnectorLibrary },
|
id: tenantId,
|
||||||
queries: { ssoConnectors },
|
queries: { ssoConnectors },
|
||||||
|
libraries: {
|
||||||
|
ssoConnector: { getSsoConnectorById, getSsoConnectors },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
] = args;
|
] = args;
|
||||||
|
|
||||||
const pathname = `/${tableToPathname(SsoConnectors.table)}`;
|
const pathname = `/${tableToPathname(SsoConnectors.table)}`;
|
||||||
const { getSsoConnectorById, getSsoConnectors } = ssoConnectorLibrary;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Get all supported single sign on connector factory details
|
Get all supported single sign on connector factory details
|
||||||
|
@ -121,10 +124,11 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...args: Rout
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const connectors = await getSsoConnectors();
|
const connectors = await getSsoConnectors();
|
||||||
|
consoleLog.info('connectors:', connectors);
|
||||||
|
|
||||||
// Fetch provider details for each connector
|
// Fetch provider details for each connector
|
||||||
const connectorsWithProviderDetails = await Promise.all(
|
const connectorsWithProviderDetails = await Promise.all(
|
||||||
connectors.map(async (connector) => fetchConnectorProviderDetails(connector))
|
connectors.map(async (connector) => fetchConnectorProviderDetails(connector, tenantId))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = connectorsWithProviderDetails;
|
ctx.body = connectorsWithProviderDetails;
|
||||||
|
@ -147,7 +151,7 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...args: Rout
|
||||||
const connector = await getSsoConnectorById(id);
|
const connector = await getSsoConnectorById(id);
|
||||||
|
|
||||||
// Fetch provider details for the connector
|
// Fetch provider details for the connector
|
||||||
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
|
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId);
|
||||||
|
|
||||||
ctx.body = connectorWithProviderDetails;
|
ctx.body = connectorWithProviderDetails;
|
||||||
|
|
||||||
|
@ -208,7 +212,7 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...args: Rout
|
||||||
new RequestError({ code: 'connector.not_found', status: 404 })
|
new RequestError({ code: 'connector.not_found', status: 404 })
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
|
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId);
|
||||||
|
|
||||||
ctx.body = connectorWithProviderDetails;
|
ctx.body = connectorWithProviderDetails;
|
||||||
|
|
||||||
|
@ -248,7 +252,7 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...args: Rout
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch provider details for the connector
|
// Fetch provider details for the connector
|
||||||
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector);
|
const connectorWithProviderDetails = await fetchConnectorProviderDetails(connector, tenantId);
|
||||||
|
|
||||||
ctx.body = connectorWithProviderDetails;
|
ctx.body = connectorWithProviderDetails;
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ await mockEsmWithActual('#src/sso/OidcConnector/utils.js', () => ({
|
||||||
const { ssoConnectorFactories } = await import('#src/sso/index.js');
|
const { ssoConnectorFactories } = await import('#src/sso/index.js');
|
||||||
const { parseFactoryDetail, fetchConnectorProviderDetails } = await import('./utils.js');
|
const { parseFactoryDetail, fetchConnectorProviderDetails } = await import('./utils.js');
|
||||||
|
|
||||||
|
const mockTenantId = 'mock_tenant_id';
|
||||||
|
|
||||||
describe('parseFactoryDetail', () => {
|
describe('parseFactoryDetail', () => {
|
||||||
it.each(Object.values(SsoProviderName))('should return correct detail for %s', (providerName) => {
|
it.each(Object.values(SsoProviderName))('should return correct detail for %s', (providerName) => {
|
||||||
const { logo, description } = ssoConnectorFactories[providerName];
|
const { logo, description } = ssoConnectorFactories[providerName];
|
||||||
|
@ -43,16 +45,15 @@ describe('parseFactoryDetail', () => {
|
||||||
|
|
||||||
describe('fetchConnectorProviderDetails', () => {
|
describe('fetchConnectorProviderDetails', () => {
|
||||||
it('providerConfig should be undefined if connector config is invalid', async () => {
|
it('providerConfig should be undefined if connector config is invalid', async () => {
|
||||||
const connector = {
|
const connector = { ...mockSsoConnector, config: { clientId: 'foo' } };
|
||||||
...mockSsoConnector,
|
const result = await fetchConnectorProviderDetails(connector, mockTenantId);
|
||||||
config: { clientId: 'foo' },
|
|
||||||
};
|
|
||||||
const result = await fetchConnectorProviderDetails(connector);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toMatchObject(
|
||||||
...connector,
|
expect.objectContaining({
|
||||||
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
...connector,
|
||||||
});
|
providerLogo: ssoConnectorFactories[connector.providerName].logo,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
expect(fetchOidcConfig).not.toBeCalled();
|
expect(fetchOidcConfig).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
@ -64,12 +65,14 @@ describe('fetchConnectorProviderDetails', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchOidcConfig.mockRejectedValueOnce(new Error('mock-error'));
|
fetchOidcConfig.mockRejectedValueOnce(new Error('mock-error'));
|
||||||
const result = await fetchConnectorProviderDetails(connector);
|
const result = await fetchConnectorProviderDetails(connector, mockTenantId);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toMatchObject(
|
||||||
...connector,
|
expect.objectContaining({
|
||||||
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
...connector,
|
||||||
});
|
providerLogo: ssoConnectorFactories[connector.providerName].logo,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer);
|
expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer);
|
||||||
});
|
});
|
||||||
|
@ -81,16 +84,18 @@ describe('fetchConnectorProviderDetails', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchOidcConfig.mockResolvedValueOnce({ tokenEndpoint: 'http://example.com/token' });
|
fetchOidcConfig.mockResolvedValueOnce({ tokenEndpoint: 'http://example.com/token' });
|
||||||
const result = await fetchConnectorProviderDetails(connector);
|
const result = await fetchConnectorProviderDetails(connector, mockTenantId);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toMatchObject(
|
||||||
...connector,
|
expect.objectContaining({
|
||||||
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
...connector,
|
||||||
providerConfig: {
|
providerLogo: ssoConnectorFactories[connector.providerName].logo,
|
||||||
...connector.config,
|
providerConfig: {
|
||||||
scope: 'openid', // Default scope
|
...connector.config,
|
||||||
tokenEndpoint: 'http://example.com/token',
|
scope: 'openid', // Default scope
|
||||||
},
|
tokenEndpoint: 'http://example.com/token',
|
||||||
});
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,15 +2,36 @@ import { type I18nPhrases } from '@logto/connector-kit';
|
||||||
import { type JsonObject } from '@logto/schemas';
|
import { type JsonObject } from '@logto/schemas';
|
||||||
import { conditional, trySafe } from '@silverhand/essentials';
|
import { conditional, trySafe } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
import { mockBaseSamlConfig, mockBaseOidcConfig } from '#src/__mocks__/sso.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { type SingleSignOnFactory, ssoConnectorFactories } from '#src/sso/index.js';
|
import { type SingleSignOnFactory, ssoConnectorFactories } from '#src/sso/index.js';
|
||||||
import { type SsoProviderName, type SupportedSsoConnector } from '#src/sso/types/index.js';
|
import { type SupportedSsoConnector } from '#src/sso/types/index.js';
|
||||||
|
import { SsoProviderName } from '#src/sso/types/index.js';
|
||||||
|
import { basicSamlConnectorConfigPartialGuard } from '#src/sso/types/saml.js';
|
||||||
|
|
||||||
import { type SsoConnectorWithProviderConfig } from './type.js';
|
import { type SsoConnectorWithProviderConfig } from './type.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
EnvSet: {
|
||||||
|
values: { isIntegrationTest },
|
||||||
|
},
|
||||||
|
} = await import('#src/env-set/index.js');
|
||||||
|
|
||||||
const isKeyOfI18nPhrases = (key: string, phrases: I18nPhrases): key is keyof I18nPhrases =>
|
const isKeyOfI18nPhrases = (key: string, phrases: I18nPhrases): key is keyof I18nPhrases =>
|
||||||
key in phrases;
|
key in phrases;
|
||||||
|
|
||||||
|
const getPartialConfigGuard = (providerName: SsoProviderName, allowPartial?: boolean) => {
|
||||||
|
if (!allowPartial) {
|
||||||
|
return ssoConnectorFactories[providerName].configGuard;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerName === SsoProviderName.SAML) {
|
||||||
|
return basicSamlConnectorConfigPartialGuard;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ssoConnectorFactories[providerName].configGuard.partial();
|
||||||
|
};
|
||||||
|
|
||||||
export const parseFactoryDetail = (
|
export const parseFactoryDetail = (
|
||||||
factory: SingleSignOnFactory<SsoProviderName>,
|
factory: SingleSignOnFactory<SsoProviderName>,
|
||||||
locale: string
|
locale: string
|
||||||
|
@ -35,11 +56,8 @@ export const parseConnectorConfig = (
|
||||||
config: JsonObject,
|
config: JsonObject,
|
||||||
allowPartial?: boolean
|
allowPartial?: boolean
|
||||||
) => {
|
) => {
|
||||||
const factory = ssoConnectorFactories[providerName];
|
const configGuard = getPartialConfigGuard(providerName, allowPartial);
|
||||||
|
const result = configGuard.safeParse(config);
|
||||||
const result = allowPartial
|
|
||||||
? factory.configGuard.partial().safeParse(config)
|
|
||||||
: factory.configGuard.safeParse(config);
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
|
@ -57,14 +75,19 @@ export const parseConnectorConfig = (
|
||||||
Return undefined if failed to fetch or parse the config.
|
Return undefined if failed to fetch or parse the config.
|
||||||
*/
|
*/
|
||||||
export const fetchConnectorProviderDetails = async (
|
export const fetchConnectorProviderDetails = async (
|
||||||
connector: SupportedSsoConnector
|
connector: SupportedSsoConnector,
|
||||||
|
tenantId: string
|
||||||
): Promise<SsoConnectorWithProviderConfig> => {
|
): Promise<SsoConnectorWithProviderConfig> => {
|
||||||
const { providerName } = connector;
|
const { providerName } = connector;
|
||||||
|
|
||||||
const { logo, constructor } = ssoConnectorFactories[providerName];
|
const { logo, constructor } = ssoConnectorFactories[providerName];
|
||||||
|
|
||||||
const providerConfig = await trySafe(async () => {
|
const providerConfig = await trySafe(async () => {
|
||||||
const instance = new constructor(connector);
|
const instance = new constructor(connector, tenantId);
|
||||||
|
// To avoid `getConfig()` being called in integration tests and throwing time out error.
|
||||||
|
if (isIntegrationTest) {
|
||||||
|
return providerName === SsoProviderName.OIDC ? mockBaseOidcConfig : mockBaseSamlConfig;
|
||||||
|
}
|
||||||
return instance.getConfig();
|
return instance.getConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
174
packages/core/src/sso/SamlConnector/index.ts
Normal file
174
packages/core/src/sso/SamlConnector/index.ts
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorErrorCodes,
|
||||||
|
type GetSession,
|
||||||
|
type SetSession,
|
||||||
|
} from '@logto/connector-kit';
|
||||||
|
import { assert, appendPath } from '@silverhand/essentials';
|
||||||
|
import * as saml from 'samlify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BaseSamlConfig,
|
||||||
|
type BaseSamlConnectorConfig,
|
||||||
|
attributeMappingPostProcessor,
|
||||||
|
} from '../types/saml.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchSamlConfig,
|
||||||
|
getRawSamlConfig,
|
||||||
|
getUserInfoFromRawUserProfile,
|
||||||
|
samlAssertionHandler,
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
class SamlConnector {
|
||||||
|
private readonly _acsUrl: string;
|
||||||
|
constructor(
|
||||||
|
private readonly config: BaseSamlConnectorConfig,
|
||||||
|
tenantId: string,
|
||||||
|
ssoConnectorId: string
|
||||||
|
) {
|
||||||
|
this._acsUrl = appendPath(
|
||||||
|
getTenantEndpoint(tenantId, EnvSet.values),
|
||||||
|
`api/authn/saml/sso/${ssoConnectorId}`
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get acsUrl() {
|
||||||
|
return this._acsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fetch SAML config from the metadata XML file or metadata URL. Throws error if config is invalid. */
|
||||||
|
getSamlConfig = async (): Promise<BaseSamlConfig> => {
|
||||||
|
const samlConfig = await fetchSamlConfig(this.config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...samlConfig,
|
||||||
|
...this.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getIdpMetadata = async () => {
|
||||||
|
return getRawSamlConfig(this.config);
|
||||||
|
};
|
||||||
|
|
||||||
|
getAuthorizationUrl = async (
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
redirectUri,
|
||||||
|
jti,
|
||||||
|
}: {
|
||||||
|
state: string;
|
||||||
|
redirectUri: string;
|
||||||
|
jti: string;
|
||||||
|
},
|
||||||
|
setSession: SetSession
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
entityId: entityID,
|
||||||
|
x509Certificate,
|
||||||
|
nameIdFormat,
|
||||||
|
signingAlgorithm,
|
||||||
|
} = await this.getSamlConfig();
|
||||||
|
|
||||||
|
assert(
|
||||||
|
setSession,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
|
||||||
|
message: 'Function `setSession()` is not implemented.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const storage = { state, redirectUri, jti };
|
||||||
|
await setSession(storage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpMetadataXml = await getRawSamlConfig(this.config);
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
const identityProvider = saml.IdentityProvider({
|
||||||
|
wantAuthnRequestsSigned: true, // Sign auth request by default
|
||||||
|
metadata: idpMetadataXml,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
const serviceProvider = saml.ServiceProvider({
|
||||||
|
entityID,
|
||||||
|
relayState: jti,
|
||||||
|
nameIDFormat: nameIdFormat,
|
||||||
|
signingCert: x509Certificate,
|
||||||
|
authnRequestsSigned: true, // Sign auth request by default
|
||||||
|
requestSignatureAlgorithm: signingAlgorithm,
|
||||||
|
assertionConsumerService: [
|
||||||
|
{
|
||||||
|
Location: this._acsUrl,
|
||||||
|
Binding: saml.Constants.BindingNamespace.Post,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginRequest = serviceProvider.createLoginRequest(identityProvider, 'redirect');
|
||||||
|
|
||||||
|
return loginRequest.context;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.General, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getUserInfo = async (_data: unknown, getSession: GetSession) => {
|
||||||
|
const parsedConfig = await this.getSamlConfig();
|
||||||
|
const { attributeMapping } = parsedConfig;
|
||||||
|
const profileMap = attributeMappingPostProcessor(attributeMapping);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
getSession,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
|
||||||
|
message: 'Function `getSession()` is not implemented.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { extractedRawProfile } = await getSession();
|
||||||
|
|
||||||
|
const extractedRawProfileGuard = z.record(z.string().or(z.array(z.string())));
|
||||||
|
const rawProfileParseResult = extractedRawProfileGuard.safeParse(extractedRawProfile);
|
||||||
|
|
||||||
|
if (!rawProfileParseResult.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUserProfile = rawProfileParseResult.data;
|
||||||
|
|
||||||
|
return getUserInfoFromRawUserProfile(rawUserProfile, profileMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
validateSamlAssertion = async (
|
||||||
|
assertion: Record<string, unknown>,
|
||||||
|
getSession: GetSession,
|
||||||
|
setSession: SetSession
|
||||||
|
): Promise<string> => {
|
||||||
|
const parsedConfig = await this.getSamlConfig();
|
||||||
|
const idpMetadataXml = await this.getIdpMetadata();
|
||||||
|
|
||||||
|
const connectorSession = await getSession();
|
||||||
|
const { redirectUri, state } = connectorSession;
|
||||||
|
|
||||||
|
await samlAssertionHandler(assertion, { ...parsedConfig, idpMetadataXml }, setSession);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
state,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.General, {
|
||||||
|
message: 'Can not find `state` from connector session.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
redirectUri,
|
||||||
|
new ConnectorError(ConnectorErrorCodes.General, {
|
||||||
|
message: 'Can not find `redirectUri` from connector session.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParameters = new URLSearchParams({ state });
|
||||||
|
|
||||||
|
return `${redirectUri}?${queryParameters.toString()}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SamlConnector;
|
168
packages/core/src/sso/SamlConnector/utils.ts
Normal file
168
packages/core/src/sso/SamlConnector/utils.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorErrorCodes,
|
||||||
|
socialUserInfoGuard,
|
||||||
|
type SetSession,
|
||||||
|
} from '@logto/connector-kit';
|
||||||
|
import { XMLValidator } from 'fast-xml-parser';
|
||||||
|
import { got } from 'got';
|
||||||
|
import * as saml from 'samlify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
samlMetadataGuard,
|
||||||
|
type SamlMetadata,
|
||||||
|
type BaseSamlConnectorConfig,
|
||||||
|
type ProfileMap,
|
||||||
|
MetadataType,
|
||||||
|
type BaseSamlConfig,
|
||||||
|
} from '../types/saml.js';
|
||||||
|
|
||||||
|
type ESamlHttpRequest = Parameters<saml.ServiceProviderInstance['parseLoginResponse']>[2];
|
||||||
|
|
||||||
|
const xmlValidator = (xml: string) => {
|
||||||
|
try {
|
||||||
|
XMLValidator.validate(xml, {
|
||||||
|
allowBooleanAttributes: true,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseXmlMetadata = (xml: string): SamlMetadata => {
|
||||||
|
xmlValidator(xml);
|
||||||
|
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
const idP = saml.IdentityProvider({ metadata: xml });
|
||||||
|
|
||||||
|
const rawSingleSignOnService = idP.entityMeta.getSingleSignOnService(
|
||||||
|
saml.Constants.namespace.binding.redirect
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const singleSignOnService =
|
||||||
|
typeof rawSingleSignOnService === 'string'
|
||||||
|
? rawSingleSignOnService
|
||||||
|
: Object.entries(rawSingleSignOnService).find(
|
||||||
|
([key, value]) => key === saml.Constants.namespace.binding.redirect
|
||||||
|
)?.[1];
|
||||||
|
const rawSamlMetadata = {
|
||||||
|
entityId: idP.entityMeta.getEntityID(),
|
||||||
|
/**
|
||||||
|
* See implementation in `samlify` {@link https://github.com/tngan/samlify/blob/55f845da60b18d40668885c7f7e71ed0967ef67f/src/entity.ts#L88}.
|
||||||
|
*/
|
||||||
|
nameIdFormat: idP.entitySetting.nameIDFormat,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
signInEndpoint: singleSignOnService,
|
||||||
|
signingAlgorithm: idP.entitySetting.requestSignatureAlgorithm,
|
||||||
|
// The type inference of the return type of `getX509Certificate` is any, will be guarded by later zod parser if it is not string-typed.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
x509Certificate: idP.entityMeta.getX509Certificate(saml.Constants.wording.certUse.signing),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The return type of `samlify`
|
||||||
|
const result = samlMetadataGuard.safeParse(rawSamlMetadata);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRawSamlConfig = async (config: BaseSamlConnectorConfig): Promise<string> => {
|
||||||
|
if (config.metadataType === MetadataType.URL) {
|
||||||
|
const { body } = await got.get(config.metadataUrl);
|
||||||
|
|
||||||
|
const result = z.string().safeParse(body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.metadataXml;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSamlConfig = async (config: BaseSamlConnectorConfig) => {
|
||||||
|
const rawMetadata = await getRawSamlConfig(config);
|
||||||
|
return parseXmlMetadata(rawMetadata);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserInfoFromRawUserProfile = (
|
||||||
|
rawUserProfile: Record<string, unknown>,
|
||||||
|
keyMapping: ProfileMap
|
||||||
|
) => {
|
||||||
|
const keyMap = new Map(
|
||||||
|
Object.entries(keyMapping).map(([destination, source]) => [source, destination])
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const mappedUserProfile = Object.fromEntries(
|
||||||
|
Object.entries(rawUserProfile)
|
||||||
|
.filter(([key, value]) => keyMap.get(key) && value)
|
||||||
|
.map(([key, value]) => [keyMap.get(key), value])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = socialUserInfoGuard.safeParse(mappedUserProfile);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const samlAssertionHandler = async (
|
||||||
|
request: ESamlHttpRequest,
|
||||||
|
options: BaseSamlConfig & { idpMetadataXml: string },
|
||||||
|
setSession: SetSession
|
||||||
|
): Promise<void | Record<string, unknown>> => {
|
||||||
|
const { entityId: entityID, x509Certificate, idpMetadataXml } = options;
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
const identityProvider = saml.IdentityProvider({
|
||||||
|
metadata: idpMetadataXml,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
const serviceProvider = saml.ServiceProvider({
|
||||||
|
entityID,
|
||||||
|
signingCert: x509Certificate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Used to check whether xml content is valid in format.
|
||||||
|
saml.setSchemaValidator({
|
||||||
|
validate: async (xmlContent: string) => {
|
||||||
|
try {
|
||||||
|
XMLValidator.validate(xmlContent, {
|
||||||
|
allowBooleanAttributes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assertionResult = await serviceProvider.parseLoginResponse(
|
||||||
|
identityProvider,
|
||||||
|
'post',
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
await setSession({
|
||||||
|
extractedRawProfile: {
|
||||||
|
...(Boolean(assertionResult.extract.nameID) && {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
id: assertionResult.extract.nameID,
|
||||||
|
}),
|
||||||
|
...assertionResult.extract.attributes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.General, String(error));
|
||||||
|
}
|
||||||
|
};
|
32
packages/core/src/sso/SamlSsoConnector/index.test.ts
Normal file
32
packages/core/src/sso/SamlSsoConnector/index.test.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||||
|
|
||||||
|
import { mockSsoConnector as _mockSsoConnector } from '#src/__mocks__/sso.js';
|
||||||
|
|
||||||
|
import { SsoProviderName } from '../types/index.js';
|
||||||
|
|
||||||
|
import { samlSsoConnectorFactory } from './index.js';
|
||||||
|
|
||||||
|
const mockSsoConnector = { ..._mockSsoConnector, providerName: SsoProviderName.SAML };
|
||||||
|
|
||||||
|
describe('SamlSsoConnector', () => {
|
||||||
|
it('SamlSsoConnector should contains static properties', () => {
|
||||||
|
expect(samlSsoConnectorFactory.providerName).toEqual(SsoProviderName.SAML);
|
||||||
|
expect(samlSsoConnectorFactory.configGuard).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructor should throw error if config is invalid', () => {
|
||||||
|
const result = samlSsoConnectorFactory.configGuard.safeParse(mockSsoConnector.config);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
throw new Error('Invalid config');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSamlSsoConnector = () => {
|
||||||
|
return new samlSsoConnectorFactory.constructor(mockSsoConnector, 'http://localhost:3001/api');
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(createSamlSsoConnector).toThrow(
|
||||||
|
new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
38
packages/core/src/sso/SamlSsoConnector/index.ts
Normal file
38
packages/core/src/sso/SamlSsoConnector/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||||
|
import { type SsoConnector } from '@logto/schemas';
|
||||||
|
|
||||||
|
import SamlConnector from '../SamlConnector/index.js';
|
||||||
|
import { type SingleSignOnFactory } from '../index.js';
|
||||||
|
import { type SingleSignOn, SsoProviderName } from '../types/index.js';
|
||||||
|
import { baseSamlConnectorConfigGuard } from '../types/saml.js';
|
||||||
|
|
||||||
|
export class SamlSsoConnector extends SamlConnector implements SingleSignOn {
|
||||||
|
constructor(
|
||||||
|
private readonly _data: SsoConnector,
|
||||||
|
tenantId: string
|
||||||
|
) {
|
||||||
|
const parseConfigResult = baseSamlConnectorConfigGuard.safeParse(_data.config);
|
||||||
|
|
||||||
|
if (!parseConfigResult.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
super(parseConfigResult.data, tenantId, _data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get data() {
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig = async () => this.getSamlConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const samlSsoConnectorFactory: SingleSignOnFactory<SsoProviderName.SAML> = {
|
||||||
|
providerName: SsoProviderName.SAML,
|
||||||
|
logo: 'saml.svg',
|
||||||
|
description: {
|
||||||
|
en: ' This connector is used to connect to SAML single sign-on identity provider.',
|
||||||
|
},
|
||||||
|
configGuard: baseSamlConnectorConfigGuard,
|
||||||
|
constructor: SamlSsoConnector,
|
||||||
|
};
|
|
@ -1,15 +1,21 @@
|
||||||
import { type I18nPhrases } from '@logto/connector-kit';
|
import { type I18nPhrases } from '@logto/connector-kit';
|
||||||
|
|
||||||
import { oidcSsoConnectorFactory, type OidcSsoConnector } from './OidcSsoConnector/index.js';
|
import { oidcSsoConnectorFactory, type OidcSsoConnector } from './OidcSsoConnector/index.js';
|
||||||
|
import { type SamlSsoConnector, samlSsoConnectorFactory } from './SamlSsoConnector/index.js';
|
||||||
import { SsoProviderName } from './types/index.js';
|
import { SsoProviderName } from './types/index.js';
|
||||||
import { type basicOidcConnectorConfigGuard } from './types/oidc.js';
|
import { type basicOidcConnectorConfigGuard } from './types/oidc.js';
|
||||||
|
import { type baseSamlConnectorConfigGuard } from './types/saml.js';
|
||||||
|
|
||||||
type SingleSignOnConstructor<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
type SingleSignOnConstructor<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
||||||
? typeof OidcSsoConnector
|
? typeof OidcSsoConnector
|
||||||
|
: T extends SsoProviderName.SAML
|
||||||
|
? typeof SamlSsoConnector
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
type SingleSignOnConnectorConfig<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
type SingleSignOnConnectorConfig<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
||||||
? typeof basicOidcConnectorConfigGuard
|
? typeof basicOidcConnectorConfigGuard
|
||||||
|
: T extends SsoProviderName.SAML
|
||||||
|
? typeof baseSamlConnectorConfigGuard
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type SingleSignOnFactory<T extends SsoProviderName> = {
|
export type SingleSignOnFactory<T extends SsoProviderName> = {
|
||||||
|
@ -24,6 +30,10 @@ export const ssoConnectorFactories: {
|
||||||
[key in SsoProviderName]: SingleSignOnFactory<key>;
|
[key in SsoProviderName]: SingleSignOnFactory<key>;
|
||||||
} = {
|
} = {
|
||||||
[SsoProviderName.OIDC]: oidcSsoConnectorFactory,
|
[SsoProviderName.OIDC]: oidcSsoConnectorFactory,
|
||||||
|
[SsoProviderName.SAML]: samlSsoConnectorFactory,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const standardSsoConnectorProviders = Object.freeze([SsoProviderName.OIDC]);
|
export const standardSsoConnectorProviders = Object.freeze([
|
||||||
|
SsoProviderName.OIDC,
|
||||||
|
SsoProviderName.SAML,
|
||||||
|
]);
|
||||||
|
|
|
@ -14,6 +14,7 @@ export abstract class SingleSignOn {
|
||||||
|
|
||||||
export enum SsoProviderName {
|
export enum SsoProviderName {
|
||||||
OIDC = 'OIDC',
|
OIDC = 'OIDC',
|
||||||
|
SAML = 'SAML',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SupportedSsoConnector = Omit<SsoConnector, 'providerName'> & {
|
export type SupportedSsoConnector = Omit<SsoConnector, 'providerName'> & {
|
||||||
|
|
24
packages/core/src/sso/types/saml.test.ts
Normal file
24
packages/core/src/sso/types/saml.test.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { attributeMappingPostProcessor } from './saml.js';
|
||||||
|
|
||||||
|
const expectedDefaultAttributeMapping = {
|
||||||
|
id: 'id',
|
||||||
|
email: 'email',
|
||||||
|
phone: 'phone',
|
||||||
|
name: 'name',
|
||||||
|
avatar: 'avatar',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('attributeMappingPostProcessor', () => {
|
||||||
|
it('should fallback to `expectedDefaultAttributeMapping` if no other attribute mapping is specified', () => {
|
||||||
|
expect(attributeMappingPostProcessor()).toEqual(expectedDefaultAttributeMapping);
|
||||||
|
expect(attributeMappingPostProcessor({})).toEqual(expectedDefaultAttributeMapping);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite specified attributes of `expectedDefaultAttributeMapping`', () => {
|
||||||
|
expect(attributeMappingPostProcessor({ id: 'sub', avatar: 'picture' })).toEqual({
|
||||||
|
...expectedDefaultAttributeMapping,
|
||||||
|
id: 'sub',
|
||||||
|
avatar: 'picture',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
83
packages/core/src/sso/types/saml.ts
Normal file
83
packages/core/src/sso/types/saml.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { socialUserInfoGuard, socialUserInfoKeys } from '@logto/connector-kit';
|
||||||
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
import cleanDeep from 'clean-deep';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export enum MetadataType {
|
||||||
|
XML = 'XML',
|
||||||
|
URL = 'URL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileMap = Required<z.infer<typeof socialUserInfoGuard>>;
|
||||||
|
|
||||||
|
const attributeMapGuard = socialUserInfoGuard.partial();
|
||||||
|
type AttributeMap = z.infer<typeof attributeMapGuard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full attribute mapping using specified attribute mappings with default fallback values.
|
||||||
|
*
|
||||||
|
* @param attributeMapping Specified attribute mapping stored in database
|
||||||
|
* @returns Full attribute mapping with default fallback values
|
||||||
|
*/
|
||||||
|
export const attributeMappingPostProcessor = (attributeMapping?: AttributeMap): ProfileMap => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
...(Object.fromEntries(socialUserInfoKeys.map((key) => [key, key])) as ProfileMap),
|
||||||
|
...conditional(attributeMapping && cleanDeep(attributeMapping)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const basicSamlCommonFields = {
|
||||||
|
attributeMapping: attributeMapGuard.optional(),
|
||||||
|
signInEndpoint: z.string().optional(),
|
||||||
|
entityId: z.string().optional(),
|
||||||
|
x509Certificate: z.string().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const baseSamlConnectorConfigGuard = z.discriminatedUnion('metadataType', [
|
||||||
|
z.object({
|
||||||
|
metadataType: z.literal(MetadataType.URL),
|
||||||
|
metadataUrl: z.string().url(),
|
||||||
|
...basicSamlCommonFields,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
metadataType: z.literal(MetadataType.XML),
|
||||||
|
metadataXml: z.string(),
|
||||||
|
...basicSamlCommonFields,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type BaseSamlConnectorConfig = z.infer<typeof baseSamlConnectorConfigGuard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod discriminate union does not support its partial util method, we need to manually implement this.
|
||||||
|
* This is for guarding the config on creating.
|
||||||
|
*/
|
||||||
|
export const basicSamlConnectorConfigPartialGuard = z.discriminatedUnion('metadataType', [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
metadataUrl: z.string().url(),
|
||||||
|
...basicSamlCommonFields,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.merge(z.object({ metadataType: z.literal(MetadataType.URL) })),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
metadataXml: z.string(),
|
||||||
|
...basicSamlCommonFields,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.merge(z.object({ metadataType: z.literal(MetadataType.XML) })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const samlMetadataGuard = z.object({
|
||||||
|
entityId: z.string(),
|
||||||
|
nameIdFormat: z.string().array().optional(),
|
||||||
|
signInEndpoint: z.string(),
|
||||||
|
signingAlgorithm: z.string(),
|
||||||
|
x509Certificate: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SamlMetadata = z.infer<typeof samlMetadataGuard>;
|
||||||
|
|
||||||
|
export type BaseSamlConfig = BaseSamlConnectorConfig & SamlMetadata;
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { type JsonObject } from '@logto/schemas';
|
||||||
import { HTTPError } from 'got';
|
import { HTTPError } from 'got';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -9,9 +10,31 @@ import {
|
||||||
patchSsoConnectorById,
|
patchSsoConnectorById,
|
||||||
patchSsoConnectorConfigById,
|
patchSsoConnectorConfigById,
|
||||||
} from '#src/api/sso-connector.js';
|
} from '#src/api/sso-connector.js';
|
||||||
import { logtoUrl } from '#src/constants.js';
|
|
||||||
|
|
||||||
const logtoIssuer = `${logtoUrl}/oidc`;
|
const providerNames = ['OIDC', 'SAML'];
|
||||||
|
const partialConfigAndProviderNames: Array<{
|
||||||
|
providerName: string;
|
||||||
|
config: JsonObject;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
providerName: 'OIDC',
|
||||||
|
config: {
|
||||||
|
clientId: 'foo',
|
||||||
|
clientSecret: 'foo',
|
||||||
|
issuer: 'https://test.com',
|
||||||
|
scope: 'openid',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerName: 'SAML',
|
||||||
|
config: {
|
||||||
|
metadataType: 'URL',
|
||||||
|
metadataUrl: 'http://test.com',
|
||||||
|
attributeMapping: {},
|
||||||
|
entityId: 'foo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
describe('sso-connector library', () => {
|
describe('sso-connector library', () => {
|
||||||
it('should return sso-connector-factories', async () => {
|
it('should return sso-connector-factories', async () => {
|
||||||
|
@ -20,7 +43,13 @@ describe('sso-connector library', () => {
|
||||||
expect(response).toHaveProperty('standardConnectors');
|
expect(response).toHaveProperty('standardConnectors');
|
||||||
expect(response).toHaveProperty('providerConnectors');
|
expect(response).toHaveProperty('providerConnectors');
|
||||||
|
|
||||||
expect(response.standardConnectors.length).toBeGreaterThan(0);
|
expect(response.standardConnectors.length).toBe(2);
|
||||||
|
expect(
|
||||||
|
response.standardConnectors.find(({ providerName }) => providerName === 'OIDC')
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
response.standardConnectors.find(({ providerName }) => providerName === 'SAML')
|
||||||
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -50,14 +79,14 @@ describe('post sso-connectors', () => {
|
||||||
).rejects.toThrow(HTTPError);
|
).rejects.toThrow(HTTPError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new sso connector', async () => {
|
it.each(providerNames)('should create a new sso connector', async (providerName) => {
|
||||||
const response = await createSsoConnector({
|
const response = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'test',
|
connectorName: 'test',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('id');
|
expect(response).toHaveProperty('id');
|
||||||
expect(response).toHaveProperty('providerName', 'OIDC');
|
expect(response).toHaveProperty('providerName', providerName);
|
||||||
expect(response).toHaveProperty('connectorName', 'test');
|
expect(response).toHaveProperty('connectorName', 'test');
|
||||||
expect(response).toHaveProperty('config', {});
|
expect(response).toHaveProperty('config', {});
|
||||||
expect(response).toHaveProperty('domains', []);
|
expect(response).toHaveProperty('domains', []);
|
||||||
|
@ -67,48 +96,49 @@ describe('post sso-connectors', () => {
|
||||||
await deleteSsoConnectorById(response.id);
|
await deleteSsoConnectorById(response.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if invalid config is provided', async () => {
|
it.each(providerNames)('should throw if invalid config is provided', async (providerName) => {
|
||||||
await expect(
|
await expect(
|
||||||
createSsoConnector({
|
createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'test',
|
connectorName: 'test',
|
||||||
config: {
|
config: {
|
||||||
issuer: 23,
|
issuer: 23,
|
||||||
|
entityId: 123,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).rejects.toThrow(HTTPError);
|
).rejects.toThrow(HTTPError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new sso connector with partial configs', async () => {
|
it.each(partialConfigAndProviderNames)(
|
||||||
const data = {
|
'should create a new sso connector with partial configs',
|
||||||
providerName: 'OIDC',
|
async ({ providerName, config }) => {
|
||||||
connectorName: 'test',
|
const data = {
|
||||||
config: {
|
providerName,
|
||||||
clientId: 'foo',
|
connectorName: 'test',
|
||||||
issuer: 'https://test.com',
|
config,
|
||||||
},
|
domains: ['test.com'],
|
||||||
domains: ['test.com'],
|
ssoOnly: true,
|
||||||
ssoOnly: true,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const response = await createSsoConnector(data);
|
const response = await createSsoConnector(data);
|
||||||
|
|
||||||
expect(response).toHaveProperty('id');
|
expect(response).toHaveProperty('id');
|
||||||
expect(response).toHaveProperty('providerName', 'OIDC');
|
expect(response).toHaveProperty('providerName', providerName);
|
||||||
expect(response).toHaveProperty('connectorName', 'test');
|
expect(response).toHaveProperty('connectorName', 'test');
|
||||||
expect(response).toHaveProperty('config', data.config);
|
expect(response).toHaveProperty('config', data.config);
|
||||||
expect(response).toHaveProperty('domains', data.domains);
|
expect(response).toHaveProperty('domains', data.domains);
|
||||||
expect(response).toHaveProperty('ssoOnly', data.ssoOnly);
|
expect(response).toHaveProperty('ssoOnly', data.ssoOnly);
|
||||||
expect(response).toHaveProperty('syncProfile', false);
|
expect(response).toHaveProperty('syncProfile', false);
|
||||||
|
|
||||||
await deleteSsoConnectorById(response.id);
|
await deleteSsoConnectorById(response.id);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get sso-connectors', () => {
|
describe('get sso-connectors', () => {
|
||||||
it('should return sso connectors', async () => {
|
it.each(providerNames)('should return sso connectors', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'test',
|
connectorName: 'test',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -132,16 +162,16 @@ describe('get sso-connector by id', () => {
|
||||||
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return sso connector', async () => {
|
it.each(providerNames)('should return sso connector', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'integration_test connector',
|
connectorName: 'integration_test connector',
|
||||||
});
|
});
|
||||||
|
|
||||||
const connector = await getSsoConnectorById(id);
|
const connector = await getSsoConnectorById(id);
|
||||||
|
|
||||||
expect(connector).toHaveProperty('id', id);
|
expect(connector).toHaveProperty('id', id);
|
||||||
expect(connector).toHaveProperty('providerName', 'OIDC');
|
expect(connector).toHaveProperty('providerName', providerName);
|
||||||
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
||||||
expect(connector).toHaveProperty('config', {});
|
expect(connector).toHaveProperty('config', {});
|
||||||
expect(connector).toHaveProperty('domains', []);
|
expect(connector).toHaveProperty('domains', []);
|
||||||
|
@ -157,9 +187,9 @@ describe('delete sso-connector by id', () => {
|
||||||
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete sso connector', async () => {
|
it.each(providerNames)('should delete sso connector', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'integration_test connector',
|
connectorName: 'integration_test connector',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -178,9 +208,9 @@ describe('patch sso-connector by id', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should patch sso connector without config', async () => {
|
it.each(providerNames)('should patch sso connector without config', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'integration_test connector',
|
connectorName: 'integration_test connector',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -191,7 +221,7 @@ describe('patch sso-connector by id', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(connector).toHaveProperty('id', id);
|
expect(connector).toHaveProperty('id', id);
|
||||||
expect(connector).toHaveProperty('providerName', 'OIDC');
|
expect(connector).toHaveProperty('providerName', providerName);
|
||||||
expect(connector).toHaveProperty('connectorName', 'integration_test connector updated');
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector updated');
|
||||||
expect(connector).toHaveProperty('config', {});
|
expect(connector).toHaveProperty('config', {});
|
||||||
expect(connector).toHaveProperty('domains', ['test.com']);
|
expect(connector).toHaveProperty('domains', ['test.com']);
|
||||||
|
@ -201,9 +231,9 @@ describe('patch sso-connector by id', () => {
|
||||||
await deleteSsoConnectorById(id);
|
await deleteSsoConnectorById(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should directly return if no changes are made', async () => {
|
it.each(providerNames)('should directly return if no changes are made', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'integration_test connector',
|
connectorName: 'integration_test connector',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -212,7 +242,7 @@ describe('patch sso-connector by id', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(connector).toHaveProperty('id', id);
|
expect(connector).toHaveProperty('id', id);
|
||||||
expect(connector).toHaveProperty('providerName', 'OIDC');
|
expect(connector).toHaveProperty('providerName', providerName);
|
||||||
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
||||||
expect(connector).toHaveProperty('config', {});
|
expect(connector).toHaveProperty('config', {});
|
||||||
expect(connector).toHaveProperty('domains', []);
|
expect(connector).toHaveProperty('domains', []);
|
||||||
|
@ -222,17 +252,17 @@ describe('patch sso-connector by id', () => {
|
||||||
await deleteSsoConnectorById(id);
|
await deleteSsoConnectorById(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if invalid config is provided', async () => {
|
it.each(providerNames)('should throw if invalid config is provided', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'integration_test connector',
|
connectorName: 'integration_test connector',
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
patchSsoConnectorById(id, {
|
patchSsoConnectorById(id, {
|
||||||
config: {
|
config: {
|
||||||
clientId: 'foo',
|
issuer: 23,
|
||||||
issuer: logtoIssuer,
|
entityId: 123,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).rejects.toThrow(HTTPError);
|
).rejects.toThrow(HTTPError);
|
||||||
|
@ -240,36 +270,29 @@ describe('patch sso-connector by id', () => {
|
||||||
await deleteSsoConnectorById(id);
|
await deleteSsoConnectorById(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should patch sso connector with config', async () => {
|
it.each(partialConfigAndProviderNames)(
|
||||||
const { id } = await createSsoConnector({
|
'should patch sso connector with config',
|
||||||
providerName: 'OIDC',
|
async ({ providerName, config }) => {
|
||||||
connectorName: 'integration_test connector',
|
const { id } = await createSsoConnector({
|
||||||
});
|
providerName,
|
||||||
|
connectorName: 'integration_test connector',
|
||||||
|
});
|
||||||
|
|
||||||
const connector = await patchSsoConnectorById(id, {
|
const connector = await patchSsoConnectorById(id, {
|
||||||
connectorName: 'integration_test connector updated',
|
connectorName: 'integration_test connector updated',
|
||||||
config: {
|
config,
|
||||||
clientId: 'foo',
|
syncProfile: true,
|
||||||
clientSecret: 'bar',
|
});
|
||||||
issuer: logtoIssuer,
|
|
||||||
scope: 'profile email',
|
|
||||||
},
|
|
||||||
syncProfile: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(connector).toHaveProperty('id', id);
|
expect(connector).toHaveProperty('id', id);
|
||||||
expect(connector).toHaveProperty('providerName', 'OIDC');
|
expect(connector).toHaveProperty('providerName', providerName);
|
||||||
expect(connector).toHaveProperty('connectorName', 'integration_test connector updated');
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector updated');
|
||||||
expect(connector).toHaveProperty('config', {
|
expect(connector).toHaveProperty('config', config);
|
||||||
clientId: 'foo',
|
expect(connector).toHaveProperty('syncProfile', true);
|
||||||
clientSecret: 'bar',
|
|
||||||
issuer: logtoIssuer,
|
|
||||||
scope: 'profile email openid', // Should merged with default scope openid
|
|
||||||
});
|
|
||||||
expect(connector).toHaveProperty('syncProfile', true);
|
|
||||||
|
|
||||||
await deleteSsoConnectorById(id);
|
await deleteSsoConnectorById(id);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('patch sso-connector config by id', () => {
|
describe('patch sso-connector config by id', () => {
|
||||||
|
@ -277,48 +300,42 @@ describe('patch sso-connector config by id', () => {
|
||||||
await expect(patchSsoConnectorConfigById('invalid-id', {})).rejects.toThrow(HTTPError);
|
await expect(patchSsoConnectorConfigById('invalid-id', {})).rejects.toThrow(HTTPError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if invalid config is provided', async () => {
|
it.each(providerNames)('should throw if invalid config is provided', async (providerName) => {
|
||||||
const { id } = await createSsoConnector({
|
const { id } = await createSsoConnector({
|
||||||
providerName: 'OIDC',
|
providerName,
|
||||||
connectorName: 'integration_test connector',
|
connectorName: 'integration_test connector',
|
||||||
config: {
|
config: {
|
||||||
clientSecret: 'bar',
|
clientSecret: 'bar',
|
||||||
|
metadataType: 'URL',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
patchSsoConnectorConfigById(id, {
|
patchSsoConnectorConfigById(id, {
|
||||||
clientId: 'foo',
|
issuer: 23,
|
||||||
|
entityId: 123,
|
||||||
})
|
})
|
||||||
).rejects.toThrow(HTTPError);
|
).rejects.toThrow(HTTPError);
|
||||||
|
|
||||||
await deleteSsoConnectorById(id);
|
await deleteSsoConnectorById(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should patch sso connector config', async () => {
|
it.each(partialConfigAndProviderNames)(
|
||||||
const { id } = await createSsoConnector({
|
'should patch sso connector config',
|
||||||
providerName: 'OIDC',
|
async ({ providerName, config }) => {
|
||||||
connectorName: 'integration_test connector',
|
const { id } = await createSsoConnector({
|
||||||
config: {
|
providerName,
|
||||||
clientId: 'foo',
|
connectorName: 'integration_test connector',
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const connector = await patchSsoConnectorConfigById(id, {
|
const connector = await patchSsoConnectorConfigById(id, config);
|
||||||
clientSecret: 'bar',
|
|
||||||
issuer: logtoIssuer,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(connector).toHaveProperty('id', id);
|
expect(connector).toHaveProperty('id', id);
|
||||||
expect(connector).toHaveProperty('providerName', 'OIDC');
|
expect(connector).toHaveProperty('providerName', providerName);
|
||||||
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
expect(connector).toHaveProperty('connectorName', 'integration_test connector');
|
||||||
expect(connector).toHaveProperty('config', {
|
expect(connector).toHaveProperty('config', config);
|
||||||
clientId: 'foo',
|
|
||||||
clientSecret: 'bar',
|
|
||||||
issuer: logtoIssuer,
|
|
||||||
scope: 'openid',
|
|
||||||
});
|
|
||||||
|
|
||||||
await deleteSsoConnectorById(id);
|
await deleteSsoConnectorById(id);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/de/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/de/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
5
packages/phrases/src/locales/en/errors/sso-connector.ts
Normal file
5
packages/phrases/src/locales/en/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
const sso_connector = {
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/es/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/es/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/fr/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/fr/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/it/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/it/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/ja/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/ja/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/ko/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/ko/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
6
packages/phrases/src/locales/ru/errors/sso-connector.ts
Normal file
6
packages/phrases/src/locales/ru/errors/sso-connector.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -16,6 +16,7 @@ import role from './role.js';
|
||||||
import scope from './scope.js';
|
import scope from './scope.js';
|
||||||
import session from './session.js';
|
import session from './session.js';
|
||||||
import sign_in_experiences from './sign-in-experiences.js';
|
import sign_in_experiences from './sign-in-experiences.js';
|
||||||
|
import sso_connector from './sso-connector.js';
|
||||||
import storage from './storage.js';
|
import storage from './storage.js';
|
||||||
import subscription from './subscription.js';
|
import subscription from './subscription.js';
|
||||||
import swagger from './swagger.js';
|
import swagger from './swagger.js';
|
||||||
|
@ -46,6 +47,7 @@ const errors = {
|
||||||
subscription,
|
subscription,
|
||||||
application,
|
application,
|
||||||
organization,
|
organization,
|
||||||
|
sso_connector,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(errors);
|
export default Object.freeze(errors);
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
const sso_connector = {
|
||||||
|
/** UNTRANSLATED */
|
||||||
|
saml_only: 'The endpoint only applies to SAML SSO connectors.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object.freeze(sso_connector);
|
|
@ -32,6 +32,14 @@ export const socialUserInfoGuard = z.object({
|
||||||
|
|
||||||
export type SocialUserInfo = z.infer<typeof socialUserInfoGuard>;
|
export type SocialUserInfo = z.infer<typeof socialUserInfoGuard>;
|
||||||
|
|
||||||
|
export const socialUserInfoKeys = Object.freeze([
|
||||||
|
'id',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'name',
|
||||||
|
'avatar',
|
||||||
|
] satisfies Array<keyof SocialUserInfo>);
|
||||||
|
|
||||||
export type GetUserInfo = (
|
export type GetUserInfo = (
|
||||||
data: unknown,
|
data: unknown,
|
||||||
getSession: GetSession
|
getSession: GetSession
|
||||||
|
|
|
@ -3208,6 +3208,9 @@ importers:
|
||||||
etag:
|
etag:
|
||||||
specifier: ^1.8.1
|
specifier: ^1.8.1
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
|
fast-xml-parser:
|
||||||
|
specifier: ^4.2.5
|
||||||
|
version: 4.2.5
|
||||||
find-up:
|
find-up:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.3.0
|
version: 6.3.0
|
||||||
|
@ -3283,6 +3286,9 @@ importers:
|
||||||
roarr:
|
roarr:
|
||||||
specifier: ^7.11.0
|
specifier: ^7.11.0
|
||||||
version: 7.11.0
|
version: 7.11.0
|
||||||
|
samlify:
|
||||||
|
specifier: 2.8.10
|
||||||
|
version: 2.8.10
|
||||||
semver:
|
semver:
|
||||||
specifier: ^7.3.8
|
specifier: ^7.3.8
|
||||||
version: 7.3.8
|
version: 7.3.8
|
||||||
|
|
Loading…
Reference in a new issue