mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add saml sso class
This commit is contained in:
parent
6a5682ac5f
commit
40a5a18d90
13 changed files with 649 additions and 4 deletions
|
@ -25,6 +25,7 @@
|
|||
"test:report": "codecov -F core"
|
||||
},
|
||||
"dependencies": {
|
||||
"@authenio/samlify-xsd-schema-validator": "^1.0.5",
|
||||
"@aws-sdk/client-s3": "^3.315.0",
|
||||
"@azure/storage-blob": "^12.13.0",
|
||||
"@google-cloud/storage": "^7.3.0",
|
||||
|
@ -80,6 +81,7 @@
|
|||
"redis": "^4.6.5",
|
||||
"roarr": "^7.11.0",
|
||||
"semver": "^7.3.8",
|
||||
"samlify": "2.8.10",
|
||||
"slonik": "^30.0.0",
|
||||
"slonik-interceptor-preset": "^1.2.10",
|
||||
"slonik-sql-tag-raw": "^1.1.4",
|
||||
|
|
3
packages/core/src/include.d/xml-validator.d.ts
vendored
Normal file
3
packages/core/src/include.d/xml-validator.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
declare module '@authenio/samlify-xsd-schema-validator' {
|
||||
export declare const validate: (xml: string) => Promise<void>;
|
||||
}
|
|
@ -51,7 +51,7 @@ describe('fetchConnectorProviderDetails', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
...connector,
|
||||
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
||||
providerLogo: ssoConnectorFactories[connector.providerName].logo,
|
||||
});
|
||||
|
||||
expect(fetchOidcConfig).not.toBeCalled();
|
||||
|
@ -68,7 +68,7 @@ describe('fetchConnectorProviderDetails', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
...connector,
|
||||
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
||||
providerLogo: ssoConnectorFactories[connector.providerName].logo,
|
||||
});
|
||||
|
||||
expect(fetchOidcConfig).toBeCalledWith(connector.config.issuer);
|
||||
|
@ -85,7 +85,7 @@ describe('fetchConnectorProviderDetails', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
...connector,
|
||||
providerLogo: ssoConnectorFactories[connector.providerName as SsoProviderName].logo,
|
||||
providerLogo: ssoConnectorFactories[connector.providerName].logo,
|
||||
providerConfig: {
|
||||
...connector.config,
|
||||
scope: 'openid', // Default scope
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
198
packages/core/src/sso/SamlConnector/index.ts
Normal file
198
packages/core/src/sso/SamlConnector/index.ts
Normal 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;
|
69
packages/core/src/sso/SamlConnector/utils.test.ts
Normal file
69
packages/core/src/sso/SamlConnector/utils.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
189
packages/core/src/sso/SamlConnector/utils.ts
Normal file
189
packages/core/src/sso/SamlConnector/utils.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
import * as validator from '@authenio/samlify-xsd-schema-validator';
|
||||
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),
|
||||
};
|
||||
};
|
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)
|
||||
);
|
||||
});
|
||||
});
|
74
packages/core/src/sso/SamlSsoConnector/index.ts
Normal file
74
packages/core/src/sso/SamlSsoConnector/index.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
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 getAuthorizationUrl Get SAML auth URL.
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SAML auth URL.
|
||||
*
|
||||
* @param jti The current session id.
|
||||
*
|
||||
* @returns The SAML auth URL.
|
||||
*/
|
||||
async getAuthorizationUrl(jti: string) {
|
||||
return this.getSingleSignOnUrl(jti);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
|
@ -1,15 +1,21 @@
|
|||
import { type I18nPhrases } from '@logto/connector-kit';
|
||||
|
||||
import { oidcSsoConnectorFactory, type OidcSsoConnector } from './OidcSsoConnector/index.js';
|
||||
import { type SamlSsoConnector, samlSsoConnectorFactory } from './SamlSsoConnector/index.js';
|
||||
import { SsoProviderName } from './types/index.js';
|
||||
import { type basicOidcConnectorConfigGuard } from './types/oidc.js';
|
||||
import { type samlConnectorConfigGuard } from './types/saml.js';
|
||||
|
||||
type SingleSignOnConstructor<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
||||
? typeof OidcSsoConnector
|
||||
: T extends SsoProviderName.SAML
|
||||
? typeof SamlSsoConnector
|
||||
: never;
|
||||
|
||||
type SingleSignOnConnectorConfig<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
||||
? typeof basicOidcConnectorConfigGuard
|
||||
: T extends SsoProviderName.SAML
|
||||
? typeof samlConnectorConfigGuard
|
||||
: never;
|
||||
|
||||
export type SingleSignOnFactory<T extends SsoProviderName> = {
|
||||
|
@ -24,6 +30,10 @@ export const ssoConnectorFactories: {
|
|||
[key in SsoProviderName]: SingleSignOnFactory<key>;
|
||||
} = {
|
||||
[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 {
|
||||
OIDC = 'OIDC',
|
||||
SAML = 'SAML',
|
||||
}
|
||||
|
||||
export type SupportedSsoConnector = Omit<SsoConnector, 'providerName'> & {
|
||||
|
|
42
packages/core/src/sso/types/saml.ts
Normal file
42
packages/core/src/sso/types/saml.ts
Normal 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;
|
|
@ -3121,6 +3121,9 @@ importers:
|
|||
|
||||
packages/core:
|
||||
dependencies:
|
||||
'@authenio/samlify-xsd-schema-validator':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(samlify@2.8.10)
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.315.0
|
||||
version: 3.315.0
|
||||
|
@ -3283,6 +3286,9 @@ importers:
|
|||
roarr:
|
||||
specifier: ^7.11.0
|
||||
version: 7.11.0
|
||||
samlify:
|
||||
specifier: 2.8.10
|
||||
version: 2.8.10
|
||||
semver:
|
||||
specifier: ^7.3.8
|
||||
version: 7.3.8
|
||||
|
@ -4165,6 +4171,15 @@ packages:
|
|||
'@jridgewell/trace-mapping': 0.3.18
|
||||
dev: true
|
||||
|
||||
/@authenio/samlify-xsd-schema-validator@1.0.5(samlify@2.8.10):
|
||||
resolution: {integrity: sha512-HJjmjM1WbeB/z4nVbYEcmtIWTLPKqjrqRGEpC9lu7s03Usc4nxxfrJGjHgh3M8MvBJy4neVUoeM9rP4ym3GLgg==}
|
||||
peerDependencies:
|
||||
samlify: '>= 2.6.0'
|
||||
dependencies:
|
||||
'@authenio/xsd-schema-validator': 0.7.3
|
||||
samlify: 2.8.10
|
||||
dev: false
|
||||
|
||||
/@authenio/xml-encryption@2.0.2:
|
||||
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
|
||||
engines: {node: '>=12'}
|
||||
|
@ -4174,6 +4189,11 @@ packages:
|
|||
xpath: 0.0.32
|
||||
dev: false
|
||||
|
||||
/@authenio/xsd-schema-validator@0.7.3:
|
||||
resolution: {integrity: sha512-Jhc/Hxv90bacZr0Fv+u+PEb440zPh4mO6rw+bzEAIBiFLKCtRa/BvKGRxPdCAwsGRPuwl2hFqQGF+Lfz6Q8kFg==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/@aws-crypto/crc32@3.0.0:
|
||||
resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==}
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue