diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 27dad1929..118041021 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -1,4 +1,3 @@ -import { type SsoConnector } from '@logto/schemas'; import { createMockUtils } from '@logto/shared/esm'; import type Provider from 'oidc-provider'; @@ -7,6 +6,7 @@ import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; +import { type SingleSignOnConnectorData } from '#src/sso/types/index.js'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; @@ -49,7 +49,7 @@ const { jest .spyOn(ssoConnectorFactories.OIDC, 'constructor') - .mockImplementation((data: SsoConnector) => new MockOidcSsoConnector(data)); + .mockImplementation((data: SingleSignOnConnectorData) => new MockOidcSsoConnector(data)); const { getSsoAuthorizationUrl, diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index 40781e098..d0b07bbc7 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -24,6 +24,7 @@ import { parseConnectorConfig, fetchConnectorProviderDetails, validateConnectorDomains, + validateConnectorConfigConnectionStatus, } from './utils.js'; export default function singleSignOnRoutes(...args: RouterInitArgs) { @@ -89,7 +90,7 @@ export default function singleSignOnRoutes(...args: Rout }), async (ctx, next) => { const { body } = ctx.guard; - const { providerName, connectorName, config, ...rest } = body; + const { providerName, connectorName, config, domains, ...rest } = body; // Return 422 if the connector provider is not supported if (!isSupportedSsoProvider(providerName)) { @@ -101,17 +102,33 @@ export default function singleSignOnRoutes(...args: Rout } // Validate the connector domains if it's provided - validateConnectorDomains(rest.domains); + if (domains) { + validateConnectorDomains(domains); + } // Validate the connector config if it's provided const parsedConfig = config && parseConnectorConfig(providerName, config); const connectorId = generateStandardShortId(); + + // Check the connection status of the connector config if it's provided + if (parsedConfig) { + await validateConnectorConfigConnectionStatus( + { + id: connectorId, + providerName, + config: parsedConfig, + }, + tenantId + ); + } + const connector = await ssoConnectors.insert({ id: connectorId, providerName, connectorName, ...conditional(config && { config: parsedConfig }), + ...conditional(domains && { domains }), ...rest, }); @@ -200,14 +217,28 @@ export default function singleSignOnRoutes(...args: Rout const originalConnector = await getSsoConnectorById(id); const { providerName } = originalConnector; - const { config, ...rest } = body; + const { config, domains, ...rest } = body; // Validate the connector domains if it's provided - validateConnectorDomains(rest.domains); + if (domains) { + validateConnectorDomains(domains); + } // Validate the connector config if it's provided const parsedConfig = config && parseConnectorConfig(providerName, config); + // Check the connection status of the connector config if it's provided + if (parsedConfig) { + await validateConnectorConfigConnectionStatus( + { + id, + providerName, + config: parsedConfig, + }, + tenantId + ); + } + // Check if there's any valid update const hasValidUpdate = parsedConfig ?? Object.keys(rest).length > 0; @@ -215,6 +246,7 @@ export default function singleSignOnRoutes(...args: Rout const connector = hasValidUpdate ? await ssoConnectors.updateById(id, { ...conditional(parsedConfig && { config: parsedConfig }), + ...conditional(domains && { domains }), ...rest, }) : originalConnector; diff --git a/packages/core/src/routes/sso-connector/utils.test.ts b/packages/core/src/routes/sso-connector/utils.test.ts index 0504e0e78..64a35e593 100644 --- a/packages/core/src/routes/sso-connector/utils.test.ts +++ b/packages/core/src/routes/sso-connector/utils.test.ts @@ -106,12 +106,6 @@ describe('fetchConnectorProviderDetails', () => { }); describe('validateConnectorDomains', () => { - it('should directly return if domains are not provided', () => { - expect(() => { - validateConnectorDomains(); - }).not.toThrow(); - }); - it('should directly return if domains are empty', () => { expect(() => { validateConnectorDomains([]); diff --git a/packages/core/src/routes/sso-connector/utils.ts b/packages/core/src/routes/sso-connector/utils.ts index 660db46ef..d676b0ee9 100644 --- a/packages/core/src/routes/sso-connector/utils.ts +++ b/packages/core/src/routes/sso-connector/utils.ts @@ -9,7 +9,9 @@ import { findDuplicatedOrBlockedEmailDomains } from '@logto/schemas'; import { trySafe } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; +import SamlConnector from '#src/sso/SamlConnector/index.js'; import { type SingleSignOnFactory, ssoConnectorFactories } from '#src/sso/index.js'; +import { type SingleSignOnConnectorData } from '#src/sso/types/index.js'; const isKeyOfI18nPhrases = (key: string, phrases: I18nPhrases): key is keyof I18nPhrases => key in phrases; @@ -75,6 +77,27 @@ export const fetchConnectorProviderDetails = async ( }; }; +/** + * Validate the connector config. + * Fetch or parse the connector IdP detailed settings using the connector config. + * Throw error if the connector config is invalid. + */ +export const validateConnectorConfigConnectionStatus = async ( + connector: SingleSignOnConnectorData, + tenantId: string +) => { + const { providerName } = connector; + const { constructor } = ssoConnectorFactories[providerName]; + const instance = new constructor(connector, tenantId); + + // SAML connector's idpMetadata is optional (safely catch by the getConfig method), we need to force fetch the IdP metadata here + if (instance instanceof SamlConnector) { + return instance.getSamlIdpMetadata(); + } + + return instance.getConfig(); +}; + /** * Validate the connector domains using the domain blacklist. * - Throw error if the domains are invalid. @@ -83,7 +106,7 @@ export const fetchConnectorProviderDetails = async ( * @param domains * @returns */ -export const validateConnectorDomains = (domains?: string[]) => { +export const validateConnectorDomains = (domains: string[]) => { const { duplicatedDomains, forbiddenDomains } = findDuplicatedOrBlockedEmailDomains(domains); if (forbiddenDomains.size > 0) { diff --git a/packages/core/src/sso/GoogleWorkspaceSsoConnector/index.ts b/packages/core/src/sso/GoogleWorkspaceSsoConnector/index.ts index 1b8b27968..076517b78 100644 --- a/packages/core/src/sso/GoogleWorkspaceSsoConnector/index.ts +++ b/packages/core/src/sso/GoogleWorkspaceSsoConnector/index.ts @@ -1,9 +1,13 @@ import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; -import { type SsoConnector, SsoProviderName } from '@logto/schemas'; +import { SsoProviderName } from '@logto/schemas'; import OidcConnector from '../OidcConnector/index.js'; import { type SingleSignOnFactory } from '../index.js'; -import { type CreateSingleSignOnSession, type SingleSignOn } from '../types/index.js'; +import { + type CreateSingleSignOnSession, + type SingleSignOn, + type SingleSignOnConnectorData, +} from '../types/index.js'; import { basicOidcConnectorConfigGuard } from '../types/oidc.js'; // Google use static issue endpoint. @@ -12,7 +16,7 @@ const googleIssuer = 'https://accounts.google.com'; export class GoogleWorkspaceSsoConnector extends OidcConnector implements SingleSignOn { static googleIssuer = googleIssuer; - constructor(readonly data: SsoConnector) { + constructor(readonly data: SingleSignOnConnectorData) { const parseConfigResult = googleWorkspaceSsoConnectorConfigGuard.safeParse(data.config); if (!parseConfigResult.success) { diff --git a/packages/core/src/sso/OidcConnector/index.ts b/packages/core/src/sso/OidcConnector/index.ts index f74480f74..f0c3d3d7b 100644 --- a/packages/core/src/sso/OidcConnector/index.ts +++ b/packages/core/src/sso/OidcConnector/index.ts @@ -1,6 +1,5 @@ -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { generateStandardId } from '@logto/shared/universal'; -import { assert, conditional } from '@silverhand/essentials'; +import { conditional } from '@silverhand/essentials'; import snakecaseKeys from 'snakecase-keys'; import { @@ -62,13 +61,6 @@ class OidcConnector { setSession: CreateSingleSignOnSession, prompt?: 'login' | 'consent' | 'none' | 'select_account' ) { - assert( - setSession, - new ConnectorError(ConnectorErrorCodes.NotImplemented, { - message: 'Connector session storage is not implemented.', - }) - ); - const oidcConfig = await this.getOidcConfig(); const nonce = generateStandardId(); diff --git a/packages/core/src/sso/OidcConnector/utils.test.ts b/packages/core/src/sso/OidcConnector/utils.test.ts index 306236032..3e5dc1f73 100644 --- a/packages/core/src/sso/OidcConnector/utils.test.ts +++ b/packages/core/src/sso/OidcConnector/utils.test.ts @@ -1,7 +1,11 @@ -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { createMockUtils } from '@logto/shared/esm'; import camelcaseKeys from 'camelcase-keys'; +import { + SsoConnectorConfigErrorCodes, + SsoConnectorError, + SsoConnectorErrorCodes, +} from '../types/error.js'; import { oidcConfigResponseGuard, oidcAuthorizationResponseGuard, @@ -48,7 +52,11 @@ describe('fetchOidcConfig', () => { getMock.mockRejectedValueOnce(new MockHttpError({ body: 'invalid endpoint' })); await expect(fetchOidcConfig(issuer)).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.General, 'invalid endpoint') + new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: { issuer }, + message: SsoConnectorConfigErrorCodes.FailToFetchConfig, + error: 'invalid endpoint', + }) ); expect(getMock).toBeCalledWith(`${issuer}/.well-known/openid-configuration`, { responseType: 'json', @@ -71,7 +79,11 @@ describe('fetchOidcConfig', () => { } await expect(fetchOidcConfig(issuer)).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error) + new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: { issuer }, + message: SsoConnectorConfigErrorCodes.InvalidConfigResponse, + error: result.error.flatten(), + }) ); }); @@ -117,7 +129,13 @@ describe('fetchToken', () => { data, redirectUri ) - ).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General, result.error)); + ).rejects.toMatchError( + new SsoConnectorError(SsoConnectorErrorCodes.InvalidRequestParameters, { + url: oidcConfigResponseCamelCase.tokenEndpoint, + params: data, + error: result.error.flatten(), + }) + ); expect(postMock).not.toBeCalled(); }); @@ -134,7 +152,12 @@ describe('fetchToken', () => { data, redirectUri ) - ).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General, 'invalid response')); + ).rejects.toMatchError( + new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Fail to fetch token', + error: 'invalid response', + }) + ); expect(postMock).toBeCalledWith({ url: oidcConfigResponseCamelCase.tokenEndpoint, @@ -169,7 +192,13 @@ describe('fetchToken', () => { data, redirectUri ) - ).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error)); + ).rejects.toMatchError( + new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Invalid token response', + response: JSON.stringify(body), + error: result.error.flatten(), + }) + ); }); it('should return the token response if the token endpoint returns valid response', async () => { diff --git a/packages/core/src/sso/OidcConnector/utils.ts b/packages/core/src/sso/OidcConnector/utils.ts index 68ba78f85..8d6422a59 100644 --- a/packages/core/src/sso/OidcConnector/utils.ts +++ b/packages/core/src/sso/OidcConnector/utils.ts @@ -1,10 +1,15 @@ -import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; +import { parseJson } from '@logto/connector-kit'; import { assert } from '@silverhand/essentials'; import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys'; import { got, HTTPError } from 'got'; import { jwtVerify, createRemoteJWKSet } from 'jose'; import { z } from 'zod'; +import { + SsoConnectorError, + SsoConnectorErrorCodes, + SsoConnectorConfigErrorCodes, +} from '../types/error.js'; import { type BaseOidcConfig, type OidcConfigResponse, @@ -26,15 +31,24 @@ export const fetchOidcConfig = async ( const result = oidcConfigResponseGuard.safeParse(body); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: { issuer }, + message: SsoConnectorConfigErrorCodes.InvalidConfigResponse, + error: result.error.flatten(), + }); } return camelcaseKeys(result.data); } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, error.response.body); + if (error instanceof SsoConnectorError) { + throw error; } - throw error; + + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: { issuer }, + message: SsoConnectorConfigErrorCodes.FailToFetchConfig, + error: error instanceof HTTPError ? error.response.body : error, + }); } }; @@ -46,7 +60,11 @@ export const fetchToken = async ( const result = oidcAuthorizationResponseGuard.safeParse(data); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.General, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidRequestParameters, { + url: tokenEndpoint, + params: data, + error: result.error.flatten(), + }); } const { code } = result.data; @@ -66,15 +84,23 @@ export const fetchToken = async ( const result = oidcTokenResponseGuard.safeParse(parseJson(httpResponse.body)); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Invalid token response', + response: httpResponse.body, + error: result.error.flatten(), + }); } return camelcaseKeys(result.data); } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, error.response.body); + if (error instanceof SsoConnectorError) { + throw error; } - throw error; + + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Fail to fetch token', + error: error instanceof HTTPError ? error.response.body : error, + }); } }; @@ -92,13 +118,19 @@ export const getIdTokenClaims = async ( }); if (Math.abs((payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) { - throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, 'id_token is expired'); + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'id_token is expired', + response: payload, + }); } const result = idTokenProfileStandardClaimsGuard.safeParse(payload); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'invalid id_token', + response: payload, + }); } const { data } = result; @@ -106,16 +138,21 @@ export const getIdTokenClaims = async ( if (data.nonce) { assert( data.nonce === nonceFromSession, - new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, 'nonce claim not match') + new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'nonce does not match', + }) ); } return data; } catch (error: unknown) { - if (error instanceof ConnectorError) { + if (error instanceof SsoConnectorError) { throw error; } - throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, error); + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Fail to verify id_token', + error, + }); } }; @@ -136,16 +173,24 @@ export const getUserInfo = async (accessToken: string, userinfoEndpoint: string) .safeParse(httpResponse.body); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Invalid user info response', + response: httpResponse.body, + error: result.error.flatten(), + }); } const { data } = result; return data; } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, error.response.body); + if (error instanceof SsoConnectorError) { + throw error; } - throw error; + + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Fail to fetch user info', + error: error instanceof HTTPError ? error.response.body : error, + }); } }; diff --git a/packages/core/src/sso/OidcSsoConnector/index.test.ts b/packages/core/src/sso/OidcSsoConnector/index.test.ts index c0c06c074..0e07f6f97 100644 --- a/packages/core/src/sso/OidcSsoConnector/index.test.ts +++ b/packages/core/src/sso/OidcSsoConnector/index.test.ts @@ -1,8 +1,13 @@ -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { SsoProviderName } from '@logto/schemas'; import { mockSsoConnector } from '#src/__mocks__/sso.js'; +import { + SsoConnectorError, + SsoConnectorErrorCodes, + SsoConnectorConfigErrorCodes, +} from '../types/error.js'; + import { oidcSsoConnectorFactory } from './index.js'; describe('OidcSsoConnector', () => { @@ -23,7 +28,11 @@ describe('OidcSsoConnector', () => { }; expect(createOidcSsoConnector).toThrow( - new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error) + new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: mockSsoConnector.config, + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + error: result.error.flatten(), + }) ); }); }); diff --git a/packages/core/src/sso/OidcSsoConnector/index.ts b/packages/core/src/sso/OidcSsoConnector/index.ts index db8cf028a..808d31db5 100644 --- a/packages/core/src/sso/OidcSsoConnector/index.ts +++ b/packages/core/src/sso/OidcSsoConnector/index.ts @@ -1,17 +1,25 @@ -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; -import { type SsoConnector, SsoProviderName } from '@logto/schemas'; +import { SsoProviderName } from '@logto/schemas'; import OidcConnector from '../OidcConnector/index.js'; import { type SingleSignOnFactory } from '../index.js'; -import { type SingleSignOn } from '../types/index.js'; +import { + SsoConnectorError, + SsoConnectorErrorCodes, + SsoConnectorConfigErrorCodes, +} from '../types/error.js'; +import { type SingleSignOn, type SingleSignOnConnectorData } from '../types/index.js'; import { basicOidcConnectorConfigGuard } from '../types/oidc.js'; export class OidcSsoConnector extends OidcConnector implements SingleSignOn { - constructor(readonly data: SsoConnector) { + constructor(readonly data: SingleSignOnConnectorData) { const parseConfigResult = basicOidcConnectorConfigGuard.safeParse(data.config); if (!parseConfigResult.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: data.config, + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + error: parseConfigResult.error.flatten(), + }); } super(parseConfigResult.data); diff --git a/packages/core/src/sso/OktaSsoConnector/index.ts b/packages/core/src/sso/OktaSsoConnector/index.ts index 625f8cd99..1bc750043 100644 --- a/packages/core/src/sso/OktaSsoConnector/index.ts +++ b/packages/core/src/sso/OktaSsoConnector/index.ts @@ -1,4 +1,3 @@ -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { SsoProviderName } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import camelcaseKeys from 'camelcase-keys'; @@ -8,6 +7,7 @@ import assertThat from '#src/utils/assert-that.js'; import { fetchToken, getUserInfo, getIdTokenClaims } from '../OidcConnector/utils.js'; import { OidcSsoConnector } from '../OidcSsoConnector/index.js'; import { type SingleSignOnFactory } from '../index.js'; +import { SsoConnectorError, SsoConnectorErrorCodes } from '../types/error.js'; import { basicOidcConnectorConfigGuard } from '../types/oidc.js'; import { type ExtendedSocialUserInfo } from '../types/saml.js'; import { type SingleSignOnConnectorSession } from '../types/session.js'; @@ -33,7 +33,9 @@ export class OktaSsoConnector extends OidcSsoConnector { assertThat( accessToken, - new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, 'access_token is empty.') + new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'The access token is missing from the response.', + }) ); // Verify the id token and get the user id diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts index e061cd7b0..d1b61ab3b 100644 --- a/packages/core/src/sso/SamlConnector/index.ts +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -1,10 +1,14 @@ -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { type Optional } from '@silverhand/essentials'; import * as saml from 'samlify'; import { z } from 'zod'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; +import { + SsoConnectorConfigErrorCodes, + SsoConnectorError, + SsoConnectorErrorCodes, +} from '../types/error.js'; import { type SamlConnectorConfig, type ExtendedSocialUserInfo, @@ -83,7 +87,10 @@ class SamlConnector { */ get idpConfig() { if (!this._idpConfig) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, 'config not found'); + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: this._idpConfig, + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + }); } return this._idpConfig; @@ -110,7 +117,11 @@ class SamlConnector { const rawProfileParseResult = userProfileGuard.safeParse(samlAssertionContent); if (!rawProfileParseResult.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { + message: 'Invalid SAML assertion', + response: samlAssertionContent, + error: rawProfileParseResult.error.flatten(), + }); } const rawUserProfile = rawProfileParseResult.data; @@ -149,7 +160,14 @@ class SamlConnector { return loginRequest.context; } catch (error: unknown) { - throw new ConnectorError(ConnectorErrorCodes.General, error); + if (error instanceof SsoConnectorError) { + throw error; + } + + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidMetadata, { + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + error, + }); } } @@ -197,7 +215,11 @@ class SamlConnector { const result = samlIdentityProviderMetadataGuard.safeParse(this.idpConfig); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: this.idpConfig, + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + error: result.error.flatten(), + }); } return result.data; diff --git a/packages/core/src/sso/SamlConnector/utils.ts b/packages/core/src/sso/SamlConnector/utils.ts index ec4fd60bc..a7dc49011 100644 --- a/packages/core/src/sso/SamlConnector/utils.ts +++ b/packages/core/src/sso/SamlConnector/utils.ts @@ -1,12 +1,16 @@ import * as validator from '@authenio/samlify-node-xmllint'; -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { type Optional, conditional, appendPath } from '@silverhand/essentials'; -import { got } from 'got'; +import { HTTPError, got } from 'got'; import * as saml from 'samlify'; import { z } from 'zod'; import { ssoPath } from '#src/routes/interaction/const.js'; +import { + SsoConnectorConfigErrorCodes, + SsoConnectorError, + SsoConnectorErrorCodes, +} from '../types/error.js'; import { defaultAttributeMapping, type CustomizableAttributeMap, @@ -56,7 +60,11 @@ export const parseXmlMetadata = ( const result = samlIdentityProviderMetadataGuard.safeParse(rawSamlMetadata); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidMetadata, result.error); + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidMetadata, { + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + metadata: rawSamlMetadata, + error: result.error, + }); } return result.data; @@ -75,13 +83,23 @@ export const fetchSamlMetadataXml = async (metadataUrl: string): Promise { const connector = new samlSsoConnectorFactory.constructor(mockSsoConnector, 'default_tenant'); await expect(async () => connector.getSamlIdpMetadata()).rejects.toThrow( - new ConnectorError(ConnectorErrorCodes.InvalidConfig, 'config not found') + new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { + config: undefined, + message: SsoConnectorConfigErrorCodes.InvalidConnectorConfig, + }) ); }); diff --git a/packages/core/src/sso/SamlSsoConnector/index.ts b/packages/core/src/sso/SamlSsoConnector/index.ts index 44001b4dd..ba238d84d 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -1,11 +1,12 @@ -import { type SsoConnector, SsoProviderName } from '@logto/schemas'; +import { SsoProviderName } from '@logto/schemas'; import { conditional, trySafe } from '@silverhand/essentials'; +import RequestError from '#src/errors/RequestError/index.js'; import assertThat from '#src/utils/assert-that.js'; import SamlConnector from '../SamlConnector/index.js'; import { type SingleSignOnFactory } from '../index.js'; -import { type SingleSignOn } from '../types/index.js'; +import { type SingleSignOn, type SingleSignOnConnectorData } from '../types/index.js'; import { samlConnectorConfigGuard, type SamlMetadata } from '../types/saml.js'; import { type SingleSignOnConnectorSession, @@ -26,7 +27,7 @@ import { */ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { constructor( - readonly data: SsoConnector, + readonly data: SingleSignOnConnectorData, tenantId: string ) { const parseConfigResult = samlConnectorConfigGuard.safeParse(data.config); @@ -90,7 +91,7 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { * This method only asserts the userInfo is not null and directly return it. */ async getUserInfo({ userInfo }: SingleSignOnConnectorSession) { - assertThat(userInfo, 'session.connector_validation_session_not_found'); + assertThat(userInfo, new RequestError('session.connector_session_not_found')); return userInfo; } diff --git a/packages/core/src/sso/index.ts b/packages/core/src/sso/index.ts index 1cf4af7c7..d39b7c5ee 100644 --- a/packages/core/src/sso/index.ts +++ b/packages/core/src/sso/index.ts @@ -24,7 +24,7 @@ type SingleSignOnConstructor = { [SsoProviderName.OKTA]: typeof OktaSsoConnector; }; -type SingleSignOnConnectorConfig = { +export type SingleSignOnConnectorConfig = { [SsoProviderName.OIDC]: typeof basicOidcConnectorConfigGuard; [SsoProviderName.SAML]: typeof samlConnectorConfigGuard; [SsoProviderName.AZURE_AD]: typeof samlConnectorConfigGuard; diff --git a/packages/core/src/sso/types/error.ts b/packages/core/src/sso/types/error.ts new file mode 100644 index 000000000..7c293b97b --- /dev/null +++ b/packages/core/src/sso/types/error.ts @@ -0,0 +1,66 @@ +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; +import { type JsonObject } from '@logto/schemas'; + +export enum SsoConnectorErrorCodes { + InvalidMetadata = 'invalid_metadata', + InvalidConfig = 'invalid_config', + AuthorizationFailed = 'authorization_failed', + InvalidResponse = 'invalid_response', + InvalidRequestParameters = 'invalid_request_parameters', +} + +export enum SsoConnectorConfigErrorCodes { + InvalidConfigResponse = 'invalid_config_response', + FailToFetchConfig = 'fail_to_fetch_config', + InvalidConnectorConfig = 'invalid_connector_config', +} + +const connectorErrorCodeMap: { [key in SsoConnectorErrorCodes]: ConnectorErrorCodes } = { + [SsoConnectorErrorCodes.InvalidMetadata]: ConnectorErrorCodes.InvalidMetadata, + [SsoConnectorErrorCodes.InvalidConfig]: ConnectorErrorCodes.InvalidConfig, + [SsoConnectorErrorCodes.InvalidResponse]: ConnectorErrorCodes.InvalidResponse, + [SsoConnectorErrorCodes.InvalidRequestParameters]: ConnectorErrorCodes.InvalidRequestParameters, + [SsoConnectorErrorCodes.AuthorizationFailed]: ConnectorErrorCodes.AuthorizationFailed, +}; + +export class SsoConnectorError extends ConnectorError { + constructor( + code: SsoConnectorErrorCodes.InvalidMetadata, + data: { message: SsoConnectorConfigErrorCodes; metadata?: string | JsonObject; error?: unknown } + ); + + constructor( + code: SsoConnectorErrorCodes.InvalidConfig, + data: { + message: SsoConnectorConfigErrorCodes; + config: JsonObject | undefined; + error?: unknown; + } + ); + + constructor( + code: SsoConnectorErrorCodes.InvalidRequestParameters, + data: { url: string; params: unknown; error?: unknown } + ); + + constructor( + code: SsoConnectorErrorCodes.InvalidResponse, + data: { + url: string; + response: unknown; + error?: unknown; + } + ); + + constructor( + code: SsoConnectorErrorCodes.AuthorizationFailed, + data: { message: string; response?: unknown; error?: unknown } + ); + + constructor(code: SsoConnectorErrorCodes, data?: Record) { + super(connectorErrorCodeMap[code], { + ssoErrorCode: code, + ...data, + }); + } +} diff --git a/packages/core/src/sso/types/index.ts b/packages/core/src/sso/types/index.ts index e9db2c88c..50684b1b0 100644 --- a/packages/core/src/sso/types/index.ts +++ b/packages/core/src/sso/types/index.ts @@ -1,4 +1,4 @@ -import { type JsonObject, type SsoConnector } from '@logto/schemas'; +import { type SsoProviderName, type JsonObject, type SsoConnector } from '@logto/schemas'; export * from './session.js'; @@ -10,7 +10,13 @@ export * from './session.js'; * @method {getConfig} getConfig - Get the full-list of SSO config from the SSO provider */ export abstract class SingleSignOn { - abstract data: SsoConnector; + abstract data: SingleSignOnConnectorData; abstract getConfig: () => Promise; abstract getIssuer: () => Promise; } + +// Pick the required fields from SsoConnector Schema +// providerName must be supported by the SSO connector factories +export type SingleSignOnConnectorData = Pick & { + providerName: SsoProviderName; +}; diff --git a/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts index 9d25588d5..b0acc703b 100644 --- a/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts @@ -102,8 +102,8 @@ describe('Single Sign On Sad Path', () => { await expectRejects( postSamlAssertion({ connectorId, RelayState, SAMLResponse: samlAssertion }), { - code: 'connector.general', - statusCode: 400, + code: 'connector.authorization_failed', + statusCode: 401, } ); });