0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

Merge pull request #4806 from logto-io/yemq-log-7174-add-saml-sso-connector-class

feat(core): add SAML SSO class

feat(core,test): update SSO connector APIs for SAML class and add integration test cases
This commit is contained in:
Darcy Ye 2023-11-07 22:11:41 +08:00 committed by GitHub
commit f694256461
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 830 additions and 154 deletions

View file

@ -25,6 +25,7 @@
"test:report": "codecov -F core" "test:report": "codecov -F core"
}, },
"dependencies": { "dependencies": {
"@authenio/samlify-node-xmllint": "^2.0.0",
"@aws-sdk/client-s3": "^3.315.0", "@aws-sdk/client-s3": "^3.315.0",
"@azure/storage-blob": "^12.13.0", "@azure/storage-blob": "^12.13.0",
"@google-cloud/storage": "^7.3.0", "@google-cloud/storage": "^7.3.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",

View file

@ -8,6 +8,7 @@ import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js';
import { ssoConnectorFactories } from '#src/sso/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -22,6 +23,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
tenant: TenantContext tenant: TenantContext
) { ) {
const { const {
id: tenantId,
provider, provider,
libraries: { ssoConnector }, libraries: { ssoConnector },
} = tenant; } = tenant;
@ -74,20 +76,20 @@ 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 factory = ssoConnectorFactories[connectorData.providerName];
connectorData const connectorInstance = new factory.constructor(connectorData, tenantId);
);
// Will throw ConnectorError if failed to fetch the provider's config if (connectorInstance instanceof OidcSsoConnector) {
const redirectTo = await connectorInstance.getAuthorizationUrl( const redirectTo = await connectorInstance.getAuthorizationUrl(
{ state, redirectUri }, { state, redirectUri },
async (connectorSession: ConnectorSession) => async (connectorSession: ConnectorSession) =>
assignConnectorSessionResult(ctx, provider, connectorSession) assignConnectorSessionResult(ctx, provider, connectorSession)
); );
// TODO: Add SAML connector support later ctx.body = { redirectTo };
}
ctx.body = { redirectTo }; // TODO: Add SAML `getSingleSignOnUrl` here
} catch (error: unknown) { } catch (error: unknown) {
// Catch ConnectorError and re-throw as 500 RequestError // Catch ConnectorError and re-throw as 500 RequestError
if (error instanceof ConnectorError) { if (error instanceof ConnectorError) {

View file

@ -5,19 +5,19 @@ 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 { type AuthedRouter, type RouterInitArgs } from '../types.js';
import { import {
parseFactoryDetail, parseFactoryDetail,
parseConnectorConfig, parseConnectorConfig,
@ -28,13 +28,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
@ -124,7 +126,7 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...args: Rout
// 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 +149,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 +210,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 +250,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;

View file

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

View file

@ -4,7 +4,7 @@ import { conditional, trySafe } from '@silverhand/essentials';
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, type SsoProviderName } from '#src/sso/types/index.js';
import { type SsoConnectorWithProviderConfig } from './type.js'; import { type SsoConnectorWithProviderConfig } from './type.js';
@ -57,14 +57,15 @@ 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);
return instance.getConfig(); return instance.getConfig();
}); });

View file

@ -40,6 +40,11 @@ class OidcConnector {
}; };
}; };
/** `Issuer` will be used by SSO identity to indicate the source of the identity */
async getIssuer() {
return this.config.issuer;
}
/** /**
* Generate the authorization URL for the OIDC provider * Generate the authorization URL for the OIDC provider
* *

View file

@ -0,0 +1,198 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { assert, appendPath, conditional, type Optional } from '@silverhand/essentials';
import * as saml from 'samlify';
import { z } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import { type SamlConfig, type SamlConnectorConfig, samlMetadataGuard } from '../types/saml.js';
import {
parseXmlMetadata,
getRawSamlMetadata,
handleSamlAssertion,
attributeMappingPostProcessor,
getExtendedUserInfoFromRawUserProfile,
} from './utils.js';
/**
* SAML connector
*
* @remark General connector for SAML protocol.
* This class provides the basic functionality to connect with a SAML IdP.
* All the SAML single sign-on connector should extend this class.
*
* @property config The SAML connector config
* @property acsUrl The SAML connector's assertion consumer service URL
* @property _rawSamlMetadata The cached raw SAML metadata (in XML-format) from the raw SAML SSO connector config
* @property _parsedSamlMetadata The cached parsed SAML metadata from the raw SAML SSO connector config
* @property _samlAssertionContent The cached parsed SAML assertion from IdP (with attribute mapping applied)
*
* @method getSamlConfig Parse and return SAML config from the XML-format metadata. Throws error if config is invalid.
* @method parseSamlAssertion Parse and store the SAML assertion from IdP.
* @method getSingleSignOnUrl Get the SAML SSO URL.
* @method getIdpXmlMetadata Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config.
*/
class SamlConnector {
readonly acsUrl: string;
private _rawSamlMetadata: Optional<string>;
private _parsedSamlMetadata: Optional<SamlConfig>;
constructor(
private readonly config: SamlConnectorConfig,
tenantId: string,
ssoConnectorId: string
) {
this.acsUrl = appendPath(
getTenantEndpoint(tenantId, EnvSet.values),
// TODO: update this endpoint
`api/authn/saml/sso/${ssoConnectorId}`
).toString();
}
/**
* Get SAML config along with parsed metadata from raw SAML SSO connector config.
*
* @returns Parsed SAML config along with it's parsed metadata.
*/
async getSamlConfig(): Promise<SamlConfig> {
if (this._parsedSamlMetadata) {
return this._parsedSamlMetadata;
}
// Get raw SAML metadata ready.
await this.getIdpXmlMetadata();
const samlConfig = conditional(
this._rawSamlMetadata && parseXmlMetadata(this._rawSamlMetadata)
);
if (samlConfig) {
this._parsedSamlMetadata = { ...samlConfig, ...this.config };
return this._parsedSamlMetadata;
}
// Required fields of metadata should not be undefined.
const result = samlMetadataGuard
.pick({ signInEndpoint: true, x509Certificate: true, entityId: true })
.safeParse(this.config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
// Simply return `this.config` should be of SamlConfig type, but seems the type inference is not that smart.
this._parsedSamlMetadata = { ...this.config, ...result.data };
return this._parsedSamlMetadata;
}
/**
* Parse and return the SAML assertion from IdP (with attribute mapping applied).
*
* @param assertion The SAML assertion from IdP.
*
* @returns The parsed SAML assertion from IdP (with attribute mapping applied).
*/
async parseSamlAssertion(assertion: Record<string, unknown>) {
const parsedConfig = await this.getSamlConfig();
const profileMap = attributeMappingPostProcessor(parsedConfig.attributeMapping);
const idpMetadataXml = await this.getIdpXmlMetadata();
// Add SSO connector errors and replace connector errors.
assert(
idpMetadataXml,
new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
message: "Can not get identity provider's metadata, please check configuration.",
})
);
const samlAssertionContent = await handleSamlAssertion(assertion, {
...parsedConfig,
idpMetadataXml,
});
const userProfileGuard = z.record(z.string().or(z.array(z.string())));
const rawProfileParseResult = userProfileGuard.safeParse(samlAssertionContent);
if (!rawProfileParseResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error);
}
const rawUserProfile = rawProfileParseResult.data;
return getExtendedUserInfoFromRawUserProfile(rawUserProfile, profileMap);
}
/**
* Get the SSO URL.
*
* @param jti The current session id.
*
* @returns The SSO URL.
*/
async getSingleSignOnUrl(jti: string) {
const {
entityId: entityID,
x509Certificate,
nameIdFormat,
signingAlgorithm,
} = await this.getSamlConfig();
try {
const idpMetadataXml = await this.getIdpXmlMetadata();
// Add SSO connector errors and replace connector errors.
assert(
idpMetadataXml,
new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
message: "Can not get identity provider's metadata, please check configuration.",
})
);
// 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);
}
}
/**
* Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config.
*
* @returns The raw SAML metadata in XML-format.
*/
private async getIdpXmlMetadata() {
if (this._rawSamlMetadata) {
return this._rawSamlMetadata;
}
const rawSamlMetadata = await getRawSamlMetadata(this.config);
if (rawSamlMetadata) {
this._rawSamlMetadata = rawSamlMetadata;
}
return this._rawSamlMetadata;
}
}
export default SamlConnector;

View file

@ -0,0 +1,69 @@
import { attributeMappingPostProcessor, getExtendedUserInfoFromRawUserProfile } from './utils.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',
});
});
});
describe('getExtendedUserInfoFromRawUserProfile', () => {
it('should correctly map even if attributeMap is not specified', () => {
const keyMapping = attributeMappingPostProcessor();
const rawUserProfile = {
id: 'foo',
picture: 'pic.png',
};
expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual(
rawUserProfile
);
});
it('should correctly map with specific fields specified', () => {
const keyMapping = attributeMappingPostProcessor({ id: 'sub' });
const rawUserProfile = {
sub: 'foo',
avatar: 'pic.png',
};
expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual({
id: 'foo',
avatar: 'pic.png',
});
});
it('should correctly map with specific fields specified and with extended fields unchanged', () => {
const keyMapping = attributeMappingPostProcessor({ phone: 'cell_phone', avatar: 'picture' });
const rawUserProfile = {
id: 'foo',
sub: 'bar',
email: 'test@logto.io',
cell_phone: '123456789',
picture: 'pic.png',
extend_field: 'extend_field',
};
expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual({
id: 'foo',
sub: 'bar',
email: 'test@logto.io',
phone: '123456789',
avatar: 'pic.png',
extend_field: 'extend_field',
});
});
});

View file

@ -0,0 +1,189 @@
import * as validator from '@authenio/samlify-node-xmllint';
import { ConnectorError, ConnectorErrorCodes, socialUserInfoGuard } from '@logto/connector-kit';
import { type Optional, conditional } from '@silverhand/essentials';
import { got } from 'got';
import * as saml from 'samlify';
import { z } from 'zod';
import {
samlMetadataGuard,
type SamlMetadata,
type SamlConnectorConfig,
type SamlConfig,
defaultAttributeMapping,
type CustomizableAttributeMap,
type AttributeMap,
} from '../types/saml.js';
type ESamlHttpRequest = Parameters<saml.ServiceProviderInstance['parseLoginResponse']>[2];
const extendedSocialUserInfoGuard = socialUserInfoGuard.catchall(z.unknown());
type ExtendedSocialUserInfo = z.infer<typeof extendedSocialUserInfoGuard>;
/**
* Parse XML-format raw SAML metadata and return the parsed SAML metadata.
*
* @param xml Raw SAML metadata in XML format.
* @returns The parsed SAML metadata.
*/
export const parseXmlMetadata = (xml: string): SamlMetadata => {
// eslint-disable-next-line new-cap
const idP = saml.IdentityProvider({ metadata: xml });
// Used to check whether xml content is valid in format.
saml.setSchemaValidator(validator);
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, _]) => 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;
};
/**
* Get corresponding IdP's raw SAML metadata (in XML format) from the SAML SSO connector config.
*
* @param config The raw SAML SSO connector config.
* @returns The corresponding IdP's raw SAML metadata (in XML format).
*/
export const getRawSamlMetadata = async (
config: SamlConnectorConfig
): Promise<Optional<string>> => {
const { metadata, metadataUrl } = config;
if (metadataUrl) {
try {
const { body } = await got.get(metadataUrl);
const result = z.string().safeParse(body);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
return result.data;
} catch (error: unknown) {
// HTTP request error
throw new ConnectorError(ConnectorErrorCodes.General, error);
}
}
return metadata;
};
/**
* Get the user info from the raw user profile extracted from IdP SAML assertion.
*
* @param rawUserProfile The raw user profile extracted from IdP SAML assertion.
* @param keyMapping The full attribute mapping with default values.
* @returns The mapped social user info.
*/
export const getExtendedUserInfoFromRawUserProfile = (
rawUserProfile: Record<string, unknown>,
keyMapping: AttributeMap
): ExtendedSocialUserInfo => {
const keyMap = new Map(
Object.entries(keyMapping).map(([destination, source]) => [source, destination])
);
const mappedUserProfile = Object.fromEntries(
Object.entries(rawUserProfile).map(([key, value]) => [keyMap.get(key) ?? key, value])
);
const result = extendedSocialUserInfoGuard.safeParse(mappedUserProfile);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
};
/**
* Handle the SAML assertion from the identity provider.
*
* @param request The SAML assertion sent by IdP (after getting the SAML auth request).
* @param config The full config of the SAML SSO connector.
* @returns The returned info contained in the SAML assertion.
*/
export const handleSamlAssertion = async (
request: ESamlHttpRequest,
config: SamlConfig & { idpMetadataXml: string }
): Promise<Record<string, unknown>> => {
const { entityId: entityID, x509Certificate, idpMetadataXml } = config;
// 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(validator);
try {
const assertionResult = await serviceProvider.parseLoginResponse(
identityProvider,
'post',
request
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...(Boolean(assertionResult.extract.nameID) && {
id: assertionResult.extract.nameID,
}),
...assertionResult.extract.attributes,
};
} catch (error: unknown) {
throw new ConnectorError(ConnectorErrorCodes.General, String(error));
}
};
/**
* 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 values.
*/
export const attributeMappingPostProcessor = (
attributeMapping?: CustomizableAttributeMap
): AttributeMap => {
return {
...defaultAttributeMapping,
...conditional(attributeMapping && attributeMapping),
};
};

View file

@ -0,0 +1,41 @@
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 work properly', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const createSamlSsoConnector = () =>
new samlSsoConnectorFactory.constructor(mockSsoConnector, 'default_tenant');
expect(createSamlSsoConnector).not.toThrow();
});
it('constructor should throw error if config is invalid', () => {
const temporaryMockSsoConnector = { ...mockSsoConnector, config: { metadata: 123 } };
const result = samlSsoConnectorFactory.configGuard.safeParse(temporaryMockSsoConnector.config);
if (result.success) {
throw new Error('Invalid config');
}
const createSamlSsoConnector = () => {
return new samlSsoConnectorFactory.constructor(temporaryMockSsoConnector, 'default_tenant');
};
expect(createSamlSsoConnector).toThrow(
new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error)
);
});
});

View file

@ -0,0 +1,62 @@
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 { samlConnectorConfigGuard } from '../types/saml.js';
/**
* SAML SSO connector
*
* This class extends the basic SAML connector class and add some business related utils methods.
*
* @property data The SAML connector data from the database
*
* @method getConfig Get parsed SAML config along with it's metadata. Throws error if config is invalid.
* @method getUserInfo Get social user info.
*/
export class SamlSsoConnector extends SamlConnector implements SingleSignOn {
constructor(
readonly data: SsoConnector,
tenantId: string
) {
const parseConfigResult = samlConnectorConfigGuard.safeParse(data.config);
if (!parseConfigResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error);
}
super(parseConfigResult.data, tenantId, data.id);
}
/**
* Get parsed SAML connector's config along with it's metadata. Throws error if config is invalid.
*
* @returns Parsed SAML connector config and it's metadata.
*/
async getConfig() {
return this.getSamlConfig();
}
/**
* Get social user info.
*
* @param assertion The SAML assertion from IdP.
*
* @returns The social user info extracted from SAML assertion.
*/
async getUserInfo(assertion: Record<string, unknown>) {
return this.parseSamlAssertion(assertion);
}
}
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: samlConnectorConfigGuard,
constructor: SamlSsoConnector,
};

View file

@ -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 samlConnectorConfigGuard } 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 samlConnectorConfigGuard
: 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,
]);

View file

@ -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'> & {

View file

@ -0,0 +1,42 @@
import { socialUserInfoGuard } from '@logto/connector-kit';
import { jsonGuard } from '@logto/schemas';
import { z } from 'zod';
// Since the SAML SSO user info will extend the basic social user info (will contain extra info like `organization`, `role` etc.), but for now we haven't decide what should be included in extended user info, so we just use the basic social user info guard here to keep SSOT.
const samlAttributeMappingGuard = socialUserInfoGuard;
// eslint-disable-next-line no-restricted-syntax
export const defaultAttributeMapping = Object.fromEntries(
Object.keys(samlAttributeMappingGuard.shape).map((key) => [key, key])
) as AttributeMap;
const customizableAttributeMappingGuard = samlAttributeMappingGuard.partial();
export type CustomizableAttributeMap = z.infer<typeof customizableAttributeMappingGuard>;
export type AttributeMap = Required<CustomizableAttributeMap>;
export const samlConnectorConfigGuard = z
.object({
attributeMapping: customizableAttributeMappingGuard,
signInEndpoint: z.string(),
entityId: z.string(),
x509Certificate: z.string(),
metadataUrl: z.string(),
metadata: z.string(),
})
.partial();
export type SamlConnectorConfig = z.infer<typeof samlConnectorConfigGuard>;
export const samlMetadataGuard = z
.object({
entityId: z.string(),
nameIdFormat: z.string().array().optional(),
signInEndpoint: z.string(),
signingAlgorithm: z.string().optional(),
x509Certificate: z.string(),
})
.catchall(jsonGuard); // Allow extra fields, also need to fit the `JsonObject` type.
export type SamlMetadata = z.infer<typeof samlMetadataGuard>;
export type SamlConfig = SamlConnectorConfig & SamlMetadata;

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@ import { logtoConsoleUrl, logtoUrl, logtoCloudUrl } from '#src/constants.js';
const api = got.extend({ const api = got.extend({
prefixUrl: new URL('/api', logtoUrl), prefixUrl: new URL('/api', logtoUrl),
timeout: { response: 5000 }, // The default is 60s which is way too long for tests.
}); });
export default api; export default api;

View file

@ -1,5 +1,9 @@
import { HTTPError } from 'got'; import { HTTPError } from 'got';
import {
providerNames,
partialConfigAndProviderNames,
} from '#src/__mocks__/sso-connectors-mock.js';
import { import {
getSsoConnectorFactories, getSsoConnectorFactories,
createSsoConnector, createSsoConnector,
@ -9,9 +13,6 @@ 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`;
describe('sso-connector library', () => { describe('sso-connector library', () => {
it('should return sso-connector-factories', async () => { it('should return sso-connector-factories', async () => {
@ -20,7 +21,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 +57,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 +74,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',
}); });
@ -120,8 +128,11 @@ describe('get sso-connectors', () => {
expect(connector).toBeDefined(); expect(connector).toBeDefined();
expect(connector?.providerLogo).toBeDefined(); expect(connector?.providerLogo).toBeDefined();
// Invalid config // Empty config object is a valid SAML config.
expect(connector?.providerConfig).toBeUndefined(); if (providerName === 'OIDC') {
// Invalid config
expect(connector?.providerConfig).toBeUndefined();
}
await deleteSsoConnectorById(id); await deleteSsoConnectorById(id);
}); });
@ -132,16 +143,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 +168,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 +189,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 +202,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 +212,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 +223,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 +233,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 +251,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 +281,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);
}); }
);
}); });

View file

@ -3121,6 +3121,9 @@ importers:
packages/core: packages/core:
dependencies: dependencies:
'@authenio/samlify-node-xmllint':
specifier: ^2.0.0
version: 2.0.0(samlify@2.8.10)
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.315.0 specifier: ^3.315.0
version: 3.315.0 version: 3.315.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
@ -4165,6 +4171,15 @@ packages:
'@jridgewell/trace-mapping': 0.3.18 '@jridgewell/trace-mapping': 0.3.18
dev: true dev: true
/@authenio/samlify-node-xmllint@2.0.0(samlify@2.8.10):
resolution: {integrity: sha512-V9cQ0CHqu3JwOmbSecGPUnzIES5kHxD00FEZKnWh90ksQUJG5/TscV2r9XLbKp7MlRMOSUfWxecM35xPSLFdSg==}
peerDependencies:
samlify: '>= 2.6.0'
dependencies:
node-xmllint: 1.0.0
samlify: 2.8.10
dev: false
/@authenio/xml-encryption@2.0.2: /@authenio/xml-encryption@2.0.2:
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -16809,6 +16824,10 @@ packages:
asn1: 0.2.6 asn1: 0.2.6
dev: false dev: false
/node-xmllint@1.0.0:
resolution: {integrity: sha512-71UV2HRUP+djvHpdyatiuv+Y1o8hI4ZI7bMfuuoACMLR1JJCErM4WXAclNeHd6BgHXkqeqnnAk3wpDkSQWmFXw==}
dev: false
/nodemailer@6.9.1: /nodemailer@6.9.1:
resolution: {integrity: sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==} resolution: {integrity: sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}