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:
parent
cce262a8b3
commit
030877b656
19 changed files with 348 additions and 83 deletions
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
66
packages/core/src/sso/types/error.ts
Normal file
66
packages/core/src/sso/types/error.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue