0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): guard sso config (#4962)

* feat(core): guard sso config

guard sso config

* refactor(core): simplify the sso error structure

simplify the sso error structure

* fix(test): fix integration tests

fix integration tests

* fix(test): fix integration-tests

fix integration-tests
This commit is contained in:
simeng-li 2023-11-28 11:17:46 +08:00 committed by GitHub
parent cce262a8b3
commit 030877b656
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 348 additions and 83 deletions

View file

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

View file

@ -24,6 +24,7 @@ import {
parseConnectorConfig,
fetchConnectorProviderDetails,
validateConnectorDomains,
validateConnectorConfigConnectionStatus,
} from './utils.js';
export default function singleSignOnRoutes<T extends AuthedRouter>(...args: RouterInitArgs<T>) {
@ -89,7 +90,7 @@ export default function singleSignOnRoutes<T extends AuthedRouter>(...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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...args: Rout
const connector = hasValidUpdate
? await ssoConnectors.updateById(id, {
...conditional(parsedConfig && { config: parsedConfig }),
...conditional(domains && { domains }),
...rest,
})
: originalConnector;

View file

@ -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([]);

View file

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

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Optiona
const result = z.string().safeParse(body);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidMetadata, {
message: SsoConnectorConfigErrorCodes.InvalidConfigResponse,
metadata: body,
error: result.error,
});
}
return result.data;
} catch (error: unknown) {
// HTTP request error
throw new ConnectorError(ConnectorErrorCodes.General, error);
if (error instanceof SsoConnectorError) {
throw error;
}
throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidMetadata, {
message: SsoConnectorConfigErrorCodes.FailToFetchConfig,
error: error instanceof HTTPError ? error.response.body : error,
});
}
};
@ -107,7 +125,11 @@ export const getExtendedUserInfoFromRawUserProfile = (
const result = extendedSocialUserInfoGuard.safeParse(mappedUserProfile);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid SAML assertion',
response: mappedUserProfile,
error: result.error.flatten(),
});
}
return result.data;
@ -152,7 +174,10 @@ export const handleSamlAssertion = async (
...assertionResult.extract.attributes,
};
} catch (error: unknown) {
throw new ConnectorError(ConnectorErrorCodes.General, String(error));
throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid SAML assertion',
error,
});
}
};

View file

@ -1,8 +1,12 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { SsoProviderName } from '@logto/schemas';
import { mockSsoConnector as _mockSsoConnector } from '#src/__mocks__/sso.js';
import {
SsoConnectorConfigErrorCodes,
SsoConnectorError,
SsoConnectorErrorCodes,
} from '../types/error.js';
import { type SamlConnectorConfig } from '../types/saml.js';
import { samlSsoConnectorFactory } from './index.js';
@ -35,7 +39,10 @@ describe('SamlSsoConnector', () => {
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,
})
);
});

View file

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

View file

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

View file

@ -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<string, unknown>) {
super(connectorErrorCodeMap[code], {
ssoErrorCode: code,
...data,
});
}
}

View file

@ -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<JsonObject>;
abstract getIssuer: () => Promise<string>;
}
// Pick the required fields from SsoConnector Schema
// providerName must be supported by the SSO connector factories
export type SingleSignOnConnectorData = Pick<SsoConnector, 'config' | 'id'> & {
providerName: SsoProviderName;
};

View file

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