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

refactor(toolkit,connector,core): detailed connector error messages and fix UTs

This commit is contained in:
Darcy Ye 2023-06-02 15:34:22 +08:00
parent 8178d61eca
commit 75ab0411bf
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
61 changed files with 1037 additions and 913 deletions

View file

@ -0,0 +1,35 @@
---
"@logto/connector-alipay-native": patch
"@logto/connector-alipay-web": patch
"@logto/connector-aliyun-dm": patch
"@logto/connector-aliyun-sms": patch
"@logto/connector-apple": patch
"@logto/connector-aws-ses": patch
"@logto/connector-azuread": patch
"@logto/connector-discord": patch
"@logto/connector-facebook": patch
"@logto/connector-feishu-web": patch
"@logto/connector-github": patch
"@logto/connector-google": patch
"@logto/connector-kakao": patch
"@logto/connector-logto-email": patch
"@logto/connector-logto-sms": patch
"@logto/connector-mock-email": patch
"@logto/connector-mock-standard-email": patch
"@logto/connector-mock-sms": patch
"@logto/connector-mock-social": patch
"@logto/connector-naver": patch
"@logto/connector-oauth": patch
"@logto/connector-oidc": patch
"@logto/connector-saml": patch
"@logto/connector-sendgrid-email": patch
"@logto/connector-smtp": patch
"@logto/connector-tencent-sms": patch
"@logto/connector-twilio-sms": patch
"@logto/connector-wechat-native": patch
"@logto/connector-wechat-web": patch
"@logto/core": patch
"@logto/connector-kit": patch
---
Fix the build of ConnectorError and the format of ConnectorError message to show more information.

View file

@ -72,9 +72,7 @@ describe('getAccessToken', () => {
sign: '<signature>', sign: '<signature>',
}); });
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
}); });
it('should throw when accessToken is empty', async () => { it('should throw when accessToken is empty', async () => {
@ -93,7 +91,12 @@ describe('getAccessToken', () => {
}); });
await expect( await expect(
getAccessToken('code', mockedAlipayNativeConfigWithValidPrivateKey) getAccessToken('code', mockedAlipayNativeConfigWithValidPrivateKey)
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
it('should fail with wrong code', async () => { it('should fail with wrong code', async () => {
@ -111,7 +114,13 @@ describe('getAccessToken', () => {
await expect( await expect(
getAccessToken('wrong_code', mockedAlipayNativeConfigWithValidPrivateKey) getAccessToken('wrong_code', mockedAlipayNativeConfigWithValidPrivateKey)
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: {
code: '20001',
msg: 'Invalid code',
sub_code: 'isv.code-invalid ',
},
})
); );
}); });
}); });
@ -179,7 +188,14 @@ describe('getUserInfo', () => {
await expect( await expect(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token') new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: {
code: '20001',
msg: 'Invalid auth token',
sub_code: 'aop.invalid-auth-token',
sub_msg: '无效的访问令牌',
},
})
); );
}); });
@ -200,7 +216,14 @@ describe('getUserInfo', () => {
await expect( await expect(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid auth code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: {
code: '40002',
msg: 'Invalid auth code',
sub_code: 'isv.code-invalid',
sub_msg: '授权码 (auth_code) 错误、状态不对或过期',
},
})
); );
}); });
@ -222,10 +245,12 @@ describe('getUserInfo', () => {
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Invalid parameter', data: {
code: '40002', code: '40002',
msg: 'Invalid parameter',
sub_code: 'isv.invalid-parameter', sub_code: 'isv.invalid-parameter',
sub_msg: '参数无效', sub_msg: '参数无效',
},
}) })
); );
}); });
@ -247,7 +272,9 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.InvalidResponse)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse, { message: 'user id is missing.' })
);
}); });
it('should throw with other request errors', async () => { it('should throw with other request errors', async () => {

View file

@ -24,6 +24,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -38,7 +39,12 @@ import {
invalidAccessTokenCode, invalidAccessTokenCode,
invalidAccessTokenSubCode, invalidAccessTokenSubCode,
} from './constant.js'; } from './constant.js';
import type { AlipayNativeConfig, ErrorHandler } from './types.js'; import type {
AlipayNativeConfig,
ErrorHandler,
AccessTokenResponse,
UserInfoResponse,
} from './types.js';
import { import {
alipayNativeConfigGuard, alipayNativeConfigGuard,
accessTokenResponseGuard, accessTokenResponseGuard,
@ -79,22 +85,22 @@ export const getAccessToken = async (code: string, config: AlipayNativeConfig) =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { error_response, alipay_system_oauth_token_response } =
if (!result.success) { connectorDataParser<AccessTokenResponse>(parsedBody, accessTokenResponseGuard);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
assert( assert(
alipay_system_oauth_token_response, alipay_system_oauth_token_response,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg) new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { data: error_response })
); );
const { access_token: accessToken } = alipay_system_oauth_token_response; const { access_token: accessToken } = alipay_system_oauth_token_response;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); assert(
accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
@ -102,7 +108,11 @@ export const getAccessToken = async (code: string, config: AlipayNativeConfig) =
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { auth_code } = await authorizationCallbackHandler(data); const { auth_code } = connectorDataParser<{ auth_code: string }>(
data,
z.object({ auth_code: z.string() }),
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<AlipayNativeConfig>(config, alipayNativeConfigGuard); validateConfig<AlipayNativeConfig>(config, alipayNativeConfigGuard);
@ -111,7 +121,9 @@ const getUserInfo =
assert( assert(
accessToken && config, accessToken && config,
new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters) new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters, {
message: 'access token or config is missing.',
})
); );
const initSearchParameters = { const initSearchParameters = {
@ -132,57 +144,47 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { alipay_user_info_share_response } = connectorDataParser<UserInfoResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); userInfoResponseGuard
} );
const { alipay_user_info_share_response } = result.data;
errorHandler(alipay_user_info_share_response); errorHandler(alipay_user_info_share_response);
const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response; const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response;
if (!id) { if (!id) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
message: 'user id is missing.',
});
} }
return { id, avatar, name }; return { id, avatar, name };
}; };
const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => { const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
const payload = { code, msg, sub_code, sub_msg };
if (invalidAccessTokenCode.includes(code)) { if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg); throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: payload,
});
} }
if (sub_code) { if (sub_code) {
assert( assert(
!invalidAccessTokenSubCode.includes(sub_code), !invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg) new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: payload,
})
); );
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: msg, data: payload,
code,
sub_code,
sub_msg,
}); });
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const dataGuard = z.object({ auth_code: z.string() });
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createAlipayNativeConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createAlipayNativeConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -47,7 +47,7 @@ export const alipayUserInfoShareResponseGuard = z.object({
sub_msg: z.string().optional(), sub_msg: z.string().optional(),
}); });
type AlipayUserInfoShareResponseGuard = z.infer<typeof alipayUserInfoShareResponseGuard>; type AlipayUserInfoShareResponse = z.infer<typeof alipayUserInfoShareResponseGuard>;
export const userInfoResponseGuard = z.object({ export const userInfoResponseGuard = z.object({
sign: z.string(), // To know `sign` details, see: https://opendocs.alipay.com/common/02kf5q sign: z.string(), // To know `sign` details, see: https://opendocs.alipay.com/common/02kf5q
@ -56,4 +56,4 @@ export const userInfoResponseGuard = z.object({
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>; export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type ErrorHandler = (response: AlipayUserInfoShareResponseGuard) => void; export type ErrorHandler = (response: AlipayUserInfoShareResponse) => void;

View file

@ -78,7 +78,12 @@ describe('getAccessToken', () => {
await expect( await expect(
getAccessToken('code', mockedAlipayConfigWithValidPrivateKey) getAccessToken('code', mockedAlipayConfigWithValidPrivateKey)
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
it('should fail with wrong code', async () => { it('should fail with wrong code', async () => {
@ -97,7 +102,13 @@ describe('getAccessToken', () => {
await expect( await expect(
getAccessToken('wrong_code', mockedAlipayConfigWithValidPrivateKey) getAccessToken('wrong_code', mockedAlipayConfigWithValidPrivateKey)
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: {
code: '20001',
msg: 'Invalid code',
sub_code: 'isv.code-invalid ',
},
})
); );
}); });
}); });
@ -150,9 +161,7 @@ describe('getUserInfo', () => {
it('throw General error if auth_code not provided in input', async () => { it('throw General error if auth_code not provided in input', async () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.InvalidResponse, '{}')
);
}); });
it('should throw SocialAccessTokenInvalid with code 20001', async () => { it('should throw SocialAccessTokenInvalid with code 20001', async () => {
@ -172,7 +181,14 @@ describe('getUserInfo', () => {
await expect( await expect(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token') new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: {
code: '20001',
msg: 'Invalid auth token',
sub_code: 'aop.invalid-auth-token',
sub_msg: '无效的访问令牌',
},
})
); );
}); });
@ -193,7 +209,14 @@ describe('getUserInfo', () => {
await expect( await expect(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid auth code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: {
code: '40002',
msg: 'Invalid auth code',
sub_code: 'isv.code-invalid',
sub_msg: '授权码 (auth_code) 错误、状态不对或过期',
},
})
); );
}); });
@ -215,10 +238,12 @@ describe('getUserInfo', () => {
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn()) connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Invalid parameter', data: {
code: '40002', code: '40002',
msg: 'Invalid parameter',
sub_code: 'isv.invalid-parameter', sub_code: 'isv.invalid-parameter',
sub_msg: '参数无效', sub_msg: '参数无效',
},
}) })
); );
}); });
@ -239,7 +264,7 @@ describe('getUserInfo', () => {
}); });
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ auth_code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse) new ConnectorError(ConnectorErrorCodes.InvalidResponse, { message: 'user id is missing.' })
); );
}); });

View file

@ -22,6 +22,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -37,7 +38,7 @@ import {
invalidAccessTokenCode, invalidAccessTokenCode,
invalidAccessTokenSubCode, invalidAccessTokenSubCode,
} from './constant.js'; } from './constant.js';
import type { AlipayConfig, ErrorHandler } from './types.js'; import type { AlipayConfig, ErrorHandler, AccessTokenResponse, UserInfoResponse } from './types.js';
import { alipayConfigGuard, accessTokenResponseGuard, userInfoResponseGuard } from './types.js'; import { alipayConfigGuard, accessTokenResponseGuard, userInfoResponseGuard } from './types.js';
import { signingParameters } from './utils.js'; import { signingParameters } from './utils.js';
@ -80,22 +81,22 @@ export const getAccessToken = async (code: string, config: AlipayConfig) => {
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { error_response, alipay_system_oauth_token_response } =
if (!result.success) { connectorDataParser<AccessTokenResponse>(parsedBody, accessTokenResponseGuard);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
assert( assert(
alipay_system_oauth_token_response, alipay_system_oauth_token_response,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg) new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { data: error_response })
); );
const { access_token: accessToken } = alipay_system_oauth_token_response; const { access_token: accessToken } = alipay_system_oauth_token_response;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); assert(
accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
@ -103,7 +104,7 @@ export const getAccessToken = async (code: string, config: AlipayConfig) => {
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { auth_code } = await authorizationCallbackHandler(data); const { auth_code } = connectorDataParser(data, z.object({ auth_code: z.string() }));
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<AlipayConfig>(config, alipayConfigGuard); validateConfig<AlipayConfig>(config, alipayConfigGuard);
@ -111,7 +112,9 @@ const getUserInfo =
assert( assert(
accessToken && config, accessToken && config,
new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters) new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters, {
message: 'access token or config is missing.',
})
); );
const initSearchParameters = { const initSearchParameters = {
@ -133,57 +136,41 @@ const getUserInfo =
const { body: rawBody } = httpResponse; const { body: rawBody } = httpResponse;
const result = userInfoResponseGuard.safeParse(parseJson(rawBody)); const parsedBody = parseJson(rawBody);
const { alipay_user_info_share_response } = connectorDataParser<UserInfoResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); userInfoResponseGuard
} );
const { alipay_user_info_share_response } = result.data;
errorHandler(alipay_user_info_share_response); errorHandler(alipay_user_info_share_response);
const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response; const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response;
if (!id) { if (!id) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
message: 'user id is missing.',
});
} }
return { id, avatar, name }; return { id, avatar, name };
}; };
const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => { const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
const payload = { code, msg, sub_code, sub_msg };
if (invalidAccessTokenCode.includes(code)) { if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg); throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, { data: payload });
} }
if (sub_code) { if (sub_code) {
assert( assert(
!invalidAccessTokenSubCode.includes(sub_code), !invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg) new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { data: payload })
); );
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, { data: payload });
errorDescription: msg,
code,
sub_code,
sub_msg,
});
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const dataGuard = z.object({ auth_code: z.string() });
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(parameterObject));
}
return result.data;
};
const createAlipayConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createAlipayConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -13,15 +13,18 @@ import {
ConnectorType, ConnectorType,
validateConfig, validateConfig,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { defaultMetadata } from './constant.js'; import { defaultMetadata } from './constant.js';
import { singleSendMail } from './single-send-mail.js'; import { singleSendMail } from './single-send-mail.js';
import type { AliyunDmConfig } from './types.js';
import { import {
aliyunDmConfigGuard, aliyunDmConfigGuard,
sendEmailResponseGuard, sendEmailResponseGuard,
sendMailErrorResponseGuard, sendMailErrorResponseGuard,
type SendMailErrorResponse,
type AliyunDmConfig,
type SendEmailResponse,
} from './types.js'; } from './types.js';
const sendMessage = const sendMessage =
@ -35,10 +38,10 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, data: templates,
`Cannot find template for type: ${type}` message: `Cannot find template for type: ${type}`,
) })
); );
try { try {
@ -59,13 +62,8 @@ const sendMessage =
accessKeySecret accessKeySecret
); );
const result = sendEmailResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
return connectorDataParser<SendEmailResponse>(parsedBody, sendEmailResponseGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { const {
@ -74,10 +72,10 @@ const sendMessage =
assert( assert(
typeof rawBody === 'string', typeof rawBody === 'string',
new ConnectorError( new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
ConnectorErrorCodes.InvalidResponse, message: `Invalid response raw body type: ${typeof rawBody}`,
`Invalid response raw body type: ${typeof rawBody}` data: rawBody,
) })
); );
errorHandler(rawBody); errorHandler(rawBody);
@ -88,15 +86,13 @@ const sendMessage =
}; };
const errorHandler = (errorResponseBody: string) => { const errorHandler = (errorResponseBody: string) => {
const result = sendMailErrorResponseGuard.safeParse(parseJson(errorResponseBody)); const parsedBody = parseJson(errorResponseBody);
const errorResponse = connectorDataParser<SendMailErrorResponse>(
parsedBody,
sendMailErrorResponseGuard
);
if (!result.success) { throw new ConnectorError(ConnectorErrorCodes.General, { data: errorResponse });
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { Message: errorDescription, ...rest } = result.data;
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription, ...rest });
}; };
const createAliyunDmConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => { const createAliyunDmConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {

View file

@ -13,11 +13,12 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { defaultMetadata } from './constant.js'; import { defaultMetadata } from './constant.js';
import { sendSms } from './single-send-text.js'; import { sendSms } from './single-send-text.js';
import type { AliyunSmsConfig, Template } from './types.js'; import type { AliyunSmsConfig, Template, SendSmsResponse } from './types.js';
import { aliyunSmsConfigGuard, sendSmsResponseGuard } from './types.js'; import { aliyunSmsConfigGuard, sendSmsResponseGuard } from './types.js';
const isChinaNumber = (to: string) => /^(\+86|0086|86)?\d{11}$/.test(to); const isChinaNumber = (to: string) => /^(\+86|0086|86)?\d{11}$/.test(to);
@ -41,10 +42,10 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Cannot find template for type: ${type}`,
`Cannot find template for type: ${type}` data: templates,
) })
); );
try { try {
@ -72,9 +73,8 @@ const sendMessage =
? ConnectorErrorCodes.RateLimitExceeded ? ConnectorErrorCodes.RateLimitExceeded
: ConnectorErrorCodes.General, : ConnectorErrorCodes.General,
{ {
errorDescription: Message, message: Message,
Code, data: rest,
...rest,
} }
) )
); );
@ -88,16 +88,16 @@ const sendMessage =
assert( assert(
typeof rawBody === 'string', typeof rawBody === 'string',
new ConnectorError( new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
ConnectorErrorCodes.InvalidResponse, message: `Invalid response raw body type: ${typeof rawBody}`,
`Invalid response raw body type: ${typeof rawBody}` data: rawBody,
) })
); );
const { Message, ...rest } = parseResponseString(rawBody); const { Message, ...rest } = parseResponseString(rawBody);
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: Message, message: Message,
...rest, data: rest,
}); });
} }
@ -106,13 +106,8 @@ const sendMessage =
}; };
const parseResponseString = (response: string) => { const parseResponseString = (response: string) => {
const result = sendSmsResponseGuard.safeParse(parseJson(response)); const parsedBody = parseJson(response);
return connectorDataParser<SendSmsResponse>(parsedBody, sendSmsResponseGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
}; };
const createAliyunSmsConnector: CreateConnector<SmsConnector> = async ({ getConfig }) => { const createAliyunSmsConnector: CreateConnector<SmsConnector> = async ({ getConfig }) => {

View file

@ -64,9 +64,7 @@ describe('getUserInfo', () => {
it('should throw if id token is missing', async () => { it('should throw if id token is missing', async () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
}); });
it('should throw if verify id token failed', async () => { it('should throw if verify id token failed', async () => {
@ -76,7 +74,7 @@ describe('getUserInfo', () => {
}); });
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ id_token: 'id_token' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ id_token: 'id_token' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid) new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, { data: 'id_token' })
); );
}); });
@ -87,7 +85,7 @@ describe('getUserInfo', () => {
})); }));
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ id_token: 'id_token' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ id_token: 'id_token' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid) new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, { data: 'id_token' })
); );
}); });
}); });

View file

@ -12,13 +12,14 @@ import {
ConnectorErrorCodes, ConnectorErrorCodes,
validateConfig, validateConfig,
ConnectorType, ConnectorType,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { generateStandardId } from '@logto/shared/universal'; import { generateStandardId } from '@logto/shared/universal';
import { createRemoteJWKSet, jwtVerify } from 'jose'; import { createRemoteJWKSet, jwtVerify } from 'jose';
import { scope, defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant.js'; import { scope, defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant.js';
import type { AppleConfig } from './types.js'; import type { AppleConfig, AuthorizationData } from './types.js';
import { appleConfigGuard, dataGuard } from './types.js'; import { appleConfigGuard, authorizationDataGuard } from './types.js';
const generateNonce = () => generateStandardId(); const generateNonce = () => generateStandardId();
@ -56,10 +57,16 @@ const getAuthorizationUri =
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => { async (data, getSession) => {
const { id_token: idToken } = await authorizationCallbackHandler(data); const { id_token: idToken } = connectorDataParser<AuthorizationData>(
data,
authorizationDataGuard,
ConnectorErrorCodes.General
);
if (!idToken) { if (!idToken) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid); throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, {
message: 'IdToken is not presented.',
});
} }
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
@ -79,6 +86,7 @@ const getUserInfo =
getSession, getSession,
new ConnectorError(ConnectorErrorCodes.NotImplemented, { new ConnectorError(ConnectorErrorCodes.NotImplemented, {
message: "'getSession' is not implemented.", message: "'getSession' is not implemented.",
data: payload,
}) })
); );
const { nonce: validationNonce } = await getSession(); const { nonce: validationNonce } = await getSession();
@ -87,6 +95,7 @@ const getUserInfo =
validationNonce, validationNonce,
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
message: "'nonce' not presented in session storage.", message: "'nonce' not presented in session storage.",
data: payload,
}) })
); );
@ -94,32 +103,26 @@ const getUserInfo =
validationNonce === payload.nonce, validationNonce === payload.nonce,
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, { new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, {
message: "IdToken validation failed due to 'nonce' mismatch.", message: "IdToken validation failed due to 'nonce' mismatch.",
data: payload,
}) })
); );
} }
if (!payload.sub) { if (!payload.sub) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid); throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, {
message: "IdToken validation failed due to 'sub' not presented",
data: payload,
});
} }
return { return {
id: payload.sub, id: payload.sub,
}; };
} catch { } catch {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid); throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, { data: idToken });
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createAppleConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createAppleConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -7,6 +7,8 @@ export const appleConfigGuard = z.object({
export type AppleConfig = z.infer<typeof appleConfigGuard>; export type AppleConfig = z.infer<typeof appleConfigGuard>;
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292 // https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292
export const dataGuard = z.object({ export const authorizationDataGuard = z.object({
id_token: z.string(), id_token: z.string(),
}); });
export type AuthorizationData = z.infer<typeof authorizationDataGuard>;

View file

@ -31,10 +31,10 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Cannot find template for type: ${type}`,
`Cannot find template for type: ${type}` data: templates,
) })
); );
const client: SESv2Client = makeClient(accessKeyId, accessKeySecret, region); const client: SESv2Client = makeClient(accessKeyId, accessKeySecret, region);
@ -46,13 +46,13 @@ const sendMessage =
assert( assert(
response.$metadata.httpStatusCode === 200, response.$metadata.httpStatusCode === 200,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, response) new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data: response })
); );
return response.MessageId; return response.MessageId;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof SESv2ServiceException) { if (error instanceof SESv2ServiceException) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, error.message); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data: error });
} }
throw error; throw error;

View file

@ -1,4 +1,4 @@
import { assert, conditional } from '@silverhand/essentials'; import { conditional, assert } from '@silverhand/essentials';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import path from 'node:path'; import path from 'node:path';
@ -17,10 +17,16 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { scopes, defaultMetadata, defaultTimeout, graphAPIEndpoint } from './constant.js'; import { scopes, defaultMetadata, defaultTimeout, graphAPIEndpoint } from './constant.js';
import type { AzureADConfig } from './types.js'; import type {
AzureADConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js';
import { import {
azureADConfigGuard, azureADConfigGuard,
accessTokenResponseGuard, accessTokenResponseGuard,
@ -85,16 +91,17 @@ const getAccessToken = async (config: AzureADConfig, code: string, redirectUri:
}); });
const authResult = await clientApplication.acquireTokenByCode(codeRequest); const authResult = await clientApplication.acquireTokenByCode(codeRequest);
const { accessToken } = connectorDataParser<AccessTokenResponse>(
const result = accessTokenResponseGuard.safeParse(authResult); authResult,
accessTokenResponseGuard
if (!result.success) { );
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); assert(
} accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
const { accessToken } = result.data; data: accessToken,
message: 'accessToken is empty',
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); })
);
return { accessToken }; return { accessToken };
}; };
@ -102,7 +109,11 @@ const getAccessToken = async (config: AzureADConfig, code: string, redirectUri:
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data); const { code, redirectUri } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
// Temporarily keep this as this is a refactor, which should not change the logics. // Temporarily keep this as this is a refactor, which should not change the logics.
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
@ -118,13 +129,11 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { id, mail, displayName } = connectorDataParser<UserInfoResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); userInfoResponseGuard
} );
const { id, mail, displayName } = result.data;
return { return {
id, id,
@ -133,29 +142,20 @@ const getUserInfo =
}; };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createAzureAdConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createAzureAdConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -37,3 +37,5 @@ export const authResponseGuard = z.object({
code: z.string(), code: z.string(),
redirectUri: z.string(), redirectUri: z.string(),
}); });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -66,7 +66,12 @@ describe('Discord connector', () => {
await expect( await expect(
getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' }) getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
}); });
@ -114,7 +119,7 @@ describe('Discord connector', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ code: 'code', redirectUri: 'dummyRedirectUri' }, jest.fn()) connector.getUserInfo({ code: 'code', redirectUri: 'dummyRedirectUri' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)); ).rejects.toThrow();
}); });
it('throws unrecognized error', async () => { it('throws unrecognized error', async () => {

View file

@ -3,8 +3,9 @@
* https://discord.com/developers/docs/topics/oauth2 * https://discord.com/developers/docs/topics/oauth2
*/ */
import { assert, conditional } from '@silverhand/essentials'; import { conditional, assert } from '@silverhand/essentials';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import { type z } from 'zod';
import type { import type {
GetConnectorConfig, GetConnectorConfig,
@ -20,6 +21,7 @@ import {
ConnectorErrorCodes, ConnectorErrorCodes,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -30,7 +32,12 @@ import {
defaultTimeout, defaultTimeout,
userInfoEndpoint, userInfoEndpoint,
} from './constant.js'; } from './constant.js';
import type { DiscordConfig } from './types.js'; import type {
DiscordConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js';
import { import {
discordConfigGuard, discordConfigGuard,
authResponseGuard, authResponseGuard,
@ -74,23 +81,29 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessTokenResponseGuard
} );
assert(
const { access_token: accessToken } = result.data; accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data); const { code, redirectUri } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<DiscordConfig>(config, discordConfigGuard); validateConfig<DiscordConfig>(config, discordConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri }); const { accessToken } = await getAccessToken(config, { code, redirectUri });
@ -103,13 +116,17 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const {
if (!result.success) { id,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); username: name,
} avatar,
email,
const { id, username: name, avatar, email, verified } = result.data; verified,
} = connectorDataParser<UserInfoResponse, z.input<typeof userInfoResponseGuard>>(
parsedBody,
userInfoResponseGuard
);
const rawUserInfo = { const rawUserInfo = {
id, id,
@ -121,35 +138,29 @@ const getUserInfo =
const userInfoResult = socialUserInfoGuard.safeParse(rawUserInfo); const userInfoResult = socialUserInfoGuard.safeParse(rawUserInfo);
if (!userInfoResult.success) { if (!userInfoResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userInfoResult.error); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
zodError: userInfoResult.error,
data: rawUserInfo,
});
} }
return userInfoResult.data; return userInfoResult.data;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createDiscordConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createDiscordConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -42,3 +42,5 @@ export const authorizationCallbackErrorGuard = z.object({
}); });
export const authResponseGuard = z.object({ code: z.string(), redirectUri: z.string() }); export const authResponseGuard = z.object({ code: z.string(), redirectUri: z.string() });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -86,7 +86,12 @@ describe('Facebook connector', () => {
await expect( await expect(
getAccessToken(mockedConfig, { code, redirectUri: dummyRedirectUri }) getAccessToken(mockedConfig, { code, redirectUri: dummyRedirectUri })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
}); });
@ -145,7 +150,7 @@ describe('Facebook connector', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ code, redirectUri: dummyRedirectUri }, jest.fn()) connector.getUserInfo({ code, redirectUri: dummyRedirectUri }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)); ).rejects.toThrow();
}); });
it('throws AuthorizationFailed error if error is access_denied', async () => { it('throws AuthorizationFailed error if error is access_denied', async () => {
@ -171,7 +176,14 @@ describe('Facebook connector', () => {
jest.fn() jest.fn()
) )
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, 'Permissions error.') new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, {
data: {
error: 'access_denied',
error_code: 200,
error_description: 'Permissions error.',
error_reason: 'user_denied',
},
})
); );
}); });
@ -199,10 +211,12 @@ describe('Facebook connector', () => {
) )
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
data: {
error: 'general_error', error: 'general_error',
error_code: 200, error_code: 200,
errorDescription: 'General error encountered.', error_description: 'General error encountered.',
error_reason: 'user_denied', error_reason: 'user_denied',
},
}) })
); );
}); });

View file

@ -19,6 +19,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -29,7 +30,7 @@ import {
defaultMetadata, defaultMetadata,
defaultTimeout, defaultTimeout,
} from './constant.js'; } from './constant.js';
import type { FacebookConfig } from './types.js'; import type { FacebookConfig, AccessTokenResponse, UserInfoResponse } from './types.js';
import { import {
authorizationCallbackErrorGuard, authorizationCallbackErrorGuard,
facebookConfigGuard, facebookConfigGuard,
@ -74,15 +75,18 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessTokenResponseGuard
} );
assert(
const { access_token: accessToken } = result.data; accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
@ -106,13 +110,11 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { id, email, name, picture } = connectorDataParser<UserInfoResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); userInfoResponseGuard
} );
const { id, email, name, picture } = result.data;
return { return {
id, id,
@ -122,13 +124,14 @@ const getUserInfo =
}; };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 400) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 400
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;
@ -145,21 +148,18 @@ const authorizationCallbackHandler = async (parameterObject: unknown) => {
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject); const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
if (!parsedError.success) { if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(parameterObject)); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
} data: parameterObject,
zodError: parsedError.error,
const { error, error_code, error_description, error_reason } = parsedError.data;
if (error === 'access_denied') {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
}
throw new ConnectorError(ConnectorErrorCodes.General, {
error,
error_code,
errorDescription: error_description,
error_reason,
}); });
}
throw new ConnectorError(
parsedError.data.error === 'access_denied'
? ConnectorErrorCodes.AuthorizationFailed
: ConnectorErrorCodes.General,
{ data: parsedError.data }
);
}; };
const createFacebookConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createFacebookConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {

View file

@ -74,7 +74,9 @@ describe('getAccessToken', () => {
await expect( await expect(
getAccessToken('code', '123', '123', 'http://localhost:3000') getAccessToken('code', '123', '123', 'http://localhost:3000')
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'access_token is empty') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
message: 'access_token is empty',
})
); );
}); });
@ -87,7 +89,12 @@ describe('getAccessToken', () => {
await expect( await expect(
getAccessToken('code', '123', '123', 'http://localhost:3000') getAccessToken('code', '123', '123', 'http://localhost:3000')
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: {
error: 'invalid_grant',
error_description: 'invalid code',
},
})
); );
}); });
}); });
@ -145,9 +152,7 @@ describe('getUserInfo', () => {
it('throw General error if code not provided in input', async () => { it('throw General error if code not provided in input', async () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.InvalidResponse, '{}')
);
}); });
it('should throw SocialAccessTokenInvalid with code invalid_token', async () => { it('should throw SocialAccessTokenInvalid with code invalid_token', async () => {
@ -159,7 +164,12 @@ describe('getUserInfo', () => {
await expect( await expect(
connector.getUserInfo({ code: 'error_code', redirectUri: 'http://localhost:3000' }, jest.fn()) connector.getUserInfo({ code: 'error_code', redirectUri: 'http://localhost:3000' }, jest.fn())
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid access token') new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: {
error: 'invalid_token',
error_description: 'invalid access token',
},
})
); );
}); });
@ -170,9 +180,7 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ code: 'code', redirectUri: 'http://localhost:3000' }, jest.fn()) connector.getUserInfo({ code: 'code', redirectUri: 'http://localhost:3000' }, jest.fn())
).rejects.toMatchError( ).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.InvalidResponse, 'invalid user response')
);
}); });
it('should throw with other request errors', async () => { it('should throw with other request errors', async () => {

View file

@ -1,4 +1,4 @@
import { assert, conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import type { import type {
@ -14,6 +14,7 @@ import {
ConnectorPlatform, ConnectorPlatform,
ConnectorType, ConnectorType,
validateConfig, validateConfig,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -22,7 +23,13 @@ import {
defaultMetadata, defaultMetadata,
userInfoEndpoint, userInfoEndpoint,
} from './constant.js'; } from './constant.js';
import type { FeishuConfig } from './types.js'; import type {
FeishuConfig,
FeishuAuthCode,
FeishuAccessTokenResponse,
FeishuErrorResponse,
FeishuUserInfoResponse,
} from './types.js';
import { import {
feishuAccessTokenResponse, feishuAccessTokenResponse,
feishuAuthCodeGuard, feishuAuthCodeGuard,
@ -57,16 +64,6 @@ export function getAuthorizationUri(getConfig: GetConnectorConfig): GetAuthoriza
}; };
} }
export async function authorizationCallbackHandler(data: unknown) {
const result = feishuAuthCodeGuard.safeParse(data);
assert(
result.success,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data))
);
return result.data;
}
export async function getAccessToken( export async function getAccessToken(
code: string, code: string,
appId: string, appId: string,
@ -88,44 +85,40 @@ export async function getAccessToken(
responseType: 'json', responseType: 'json',
}); });
const result = feishuAccessTokenResponse.safeParse(response.body); const { access_token: accessToken } = connectorDataParser<FeishuAccessTokenResponse>(
assert( response.body,
result.success, feishuAccessTokenResponse
new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(response.body))
); );
if (result.data.access_token.length === 0) { if (accessToken.length === 0) {
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'access_token is empty'); throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
message: 'access_token is empty',
});
} }
return { accessToken: result.data.access_token }; return { accessToken };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof ConnectorError) { if (error instanceof ConnectorError) {
throw error; throw error;
} }
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const result = feishuErrorResponse.safeParse(error.response.body); const errorResponse = connectorDataParser<FeishuErrorResponse>(
assert( error.response.body,
result.success, feishuErrorResponse
new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(error.response.body))
);
throw new ConnectorError(
ConnectorErrorCodes.SocialAuthCodeInvalid,
result.data.error_description
); );
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { data: errorResponse });
} }
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Failed to get access token', message: 'Failed to get access token',
}); });
} }
} }
export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo { export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo {
return async function (data) { return async function (data) {
const { code, redirectUri } = await authorizationCallbackHandler(data); const { code, redirectUri } = connectorDataParser<FeishuAuthCode>(data, feishuAuthCodeGuard);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<FeishuConfig>(config, feishuConfigGuard); validateConfig<FeishuConfig>(config, feishuConfigGuard);
@ -139,13 +132,14 @@ export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo {
responseType: 'json', responseType: 'json',
}); });
const result = feishuUserInfoResponse.safeParse(response.body); const {
assert( sub,
result.success, user_id,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, `invalid user response`) name,
); email,
avatar_url: avatar,
const { sub, user_id, name, email, avatar_url: avatar, mobile } = result.data; mobile,
} = connectorDataParser<FeishuUserInfoResponse>(response.body, feishuUserInfoResponse);
return { return {
id: sub, id: sub,
@ -161,24 +155,17 @@ export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo {
} }
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const result = feishuErrorResponse.safeParse(error.response.body); const errorResponse = connectorDataParser<FeishuErrorResponse>(
error.response.body,
assert( feishuErrorResponse
result.success,
new ConnectorError(
ConnectorErrorCodes.InvalidResponse,
JSON.stringify(error.response.body)
)
);
throw new ConnectorError(
ConnectorErrorCodes.SocialAccessTokenInvalid,
result.data.error_description
); );
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: errorResponse,
});
} }
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Failed to get user info', message: 'Failed to get user info',
}); });
} }
}; };

View file

@ -12,11 +12,15 @@ export const feishuAuthCodeGuard = z.object({
redirectUri: z.string(), redirectUri: z.string(),
}); });
export type FeishuAuthCode = z.infer<typeof feishuAuthCodeGuard>;
export const feishuErrorResponse = z.object({ export const feishuErrorResponse = z.object({
error: z.string(), error: z.string(),
error_description: z.string().optional(), error_description: z.string().optional(),
}); });
export type FeishuErrorResponse = z.infer<typeof feishuErrorResponse>;
export const feishuAccessTokenResponse = z.object({ export const feishuAccessTokenResponse = z.object({
access_token: z.string(), access_token: z.string(),
token_type: z.string(), token_type: z.string(),
@ -25,6 +29,8 @@ export const feishuAccessTokenResponse = z.object({
refresh_expires_in: z.number().optional(), refresh_expires_in: z.number().optional(),
}); });
export type FeishuAccessTokenResponse = z.infer<typeof feishuAccessTokenResponse>;
export const feishuUserInfoResponse = z.object({ export const feishuUserInfoResponse = z.object({
sub: z.string(), sub: z.string(),
name: z.string(), name: z.string(),
@ -42,3 +48,5 @@ export const feishuUserInfoResponse = z.object({
employee_no: z.string().nullish(), employee_no: z.string().nullish(),
mobile: z.string().nullish(), mobile: z.string().nullish(),
}); });
export type FeishuUserInfoResponse = z.infer<typeof feishuUserInfoResponse>;

View file

@ -61,7 +61,10 @@ describe('getAccessToken', () => {
.post('') .post('')
.reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' })); .reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' }));
await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toMatchError( await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
); );
}); });
}); });
@ -119,9 +122,7 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => { it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').reply(401); nock(userInfoEndpoint).get('').reply(401);
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
}); });
it('throws AuthorizationFailed error if error is access_denied', async () => { it('throws AuthorizationFailed error if error is access_denied', async () => {
@ -143,10 +144,14 @@ describe('getUserInfo', () => {
jest.fn() jest.fn()
) )
).rejects.toMatchError( ).rejects.toMatchError(
new ConnectorError( new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, {
ConnectorErrorCodes.AuthorizationFailed, data: {
'The user has denied your application access.' error: 'access_denied',
) error_description: 'The user has denied your application access.',
error_uri:
'https://docs.github.com/apps/troubleshooting-authorization-request-errors#access-denied',
},
})
); );
}); });
@ -166,12 +171,7 @@ describe('getUserInfo', () => {
}, },
jest.fn() jest.fn()
) )
).rejects.toMatchError( ).rejects.toThrow();
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
}); });
it('throws unrecognized error', async () => { it('throws unrecognized error', async () => {

View file

@ -1,4 +1,4 @@
import { assert, conditional } from '@silverhand/essentials'; import { conditional, assert } from '@silverhand/essentials';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import type { import type {
@ -14,6 +14,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import qs from 'query-string'; import qs from 'query-string';
@ -25,7 +26,7 @@ import {
defaultMetadata, defaultMetadata,
defaultTimeout, defaultTimeout,
} from './constant.js'; } from './constant.js';
import type { GithubConfig } from './types.js'; import type { GithubConfig, AccessTokenResponse, UserInfoResponse } from './types.js';
import { import {
authorizationCallbackErrorGuard, authorizationCallbackErrorGuard,
githubConfigGuard, githubConfigGuard,
@ -59,20 +60,18 @@ const authorizationCallbackHandler = async (parameterObject: unknown) => {
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject); const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
if (!parsedError.success) { if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
const { error, error_description, error_uri } = parsedError.data;
if (error === 'access_denied') {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
}
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, {
error, data: parameterObject,
errorDescription: error_description, zodError: parsedError.error,
error_uri,
}); });
}
throw new ConnectorError(
parsedError.data.error === 'access_denied'
? ConnectorErrorCodes.AuthorizationFailed
: ConnectorErrorCodes.General,
{ data: parsedError.data }
);
}; };
export const getAccessToken = async (config: GithubConfig, codeObject: { code: string }) => { export const getAccessToken = async (config: GithubConfig, codeObject: { code: string }) => {
@ -89,16 +88,18 @@ export const getAccessToken = async (config: GithubConfig, codeObject: { code: s
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body)); const parsedBody = qs.parse(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessTokenResponseGuard
} );
assert(
const { access_token: accessToken } = result.data; accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
@ -118,13 +119,13 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const {
if (!result.success) { id,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); avatar_url: avatar,
} email,
name,
const { id, avatar_url: avatar, email, name } = result.data; } = connectorDataParser<UserInfoResponse>(parsedBody, userInfoResponseGuard);
return { return {
id: String(id), id: String(id),
@ -134,13 +135,14 @@ const getUserInfo =
}; };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;

View file

@ -60,7 +60,12 @@ describe('google connector', () => {
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' }); .reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
await expect( await expect(
getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' }) getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
}); });
@ -110,7 +115,7 @@ describe('google connector', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn()) connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)); ).rejects.toThrow();
}); });
it('throws General error', async () => { it('throws General error', async () => {
@ -133,12 +138,7 @@ describe('google connector', () => {
}, },
jest.fn() jest.fn()
) )
).rejects.toMatchError( ).rejects.toThrow();
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
}); });
it('throws unrecognized error', async () => { it('throws unrecognized error', async () => {

View file

@ -18,6 +18,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -28,7 +29,7 @@ import {
defaultMetadata, defaultMetadata,
defaultTimeout, defaultTimeout,
} from './constant.js'; } from './constant.js';
import type { GoogleConfig } from './types.js'; import type { GoogleConfig, AccessTokenResponse, UserInfoResponse, AuthResponse } from './types.js';
import { import {
googleConfigGuard, googleConfigGuard,
accessTokenResponseGuard, accessTokenResponseGuard,
@ -73,15 +74,18 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessTokenResponseGuard
} );
assert(
const { access_token: accessToken } = result.data; accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
@ -89,7 +93,11 @@ export const getAccessToken = async (
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data); const { code, redirectUri } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<GoogleConfig>(config, googleConfigGuard); validateConfig<GoogleConfig>(config, googleConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri }); const { accessToken } = await getAccessToken(config, { code, redirectUri });
@ -102,13 +110,14 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const {
if (!result.success) { sub: id,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); picture: avatar,
} email,
email_verified,
const { sub: id, picture: avatar, email, email_verified, name } = result.data; name,
} = connectorDataParser<UserInfoResponse>(parsedBody, userInfoResponseGuard);
return { return {
id, id,
@ -121,25 +130,16 @@ const getUserInfo =
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const getUserInfoErrorHandler = (error: unknown) => { const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;

View file

@ -33,3 +33,5 @@ export const authResponseGuard = z.object({
code: z.string(), code: z.string(),
redirectUri: z.string(), redirectUri: z.string(),
}); });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -60,7 +60,12 @@ describe('kakao connector', () => {
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' }); .reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
await expect( await expect(
getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' }) getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
}); });
@ -114,7 +119,7 @@ describe('kakao connector', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn()) connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)); ).rejects.toThrow();
}); });
it('throws General error', async () => { it('throws General error', async () => {
@ -137,12 +142,7 @@ describe('kakao connector', () => {
}, },
jest.fn() jest.fn()
) )
).rejects.toMatchError( ).rejects.toThrow();
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
}); });
it('throws unrecognized error', async () => { it('throws unrecognized error', async () => {

View file

@ -2,7 +2,7 @@
* The Implementation of OpenID Connect of Kakao. * The Implementation of OpenID Connect of Kakao.
* https://developers.kakao.com/docs/latest/en/kakaologin/rest-api * https://developers.kakao.com/docs/latest/en/kakaologin/rest-api
*/ */
import { assert, conditional } from '@silverhand/essentials'; import { conditional, assert } from '@silverhand/essentials';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import type { import type {
@ -18,6 +18,7 @@ import {
ConnectorType, ConnectorType,
validateConfig, validateConfig,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -27,7 +28,7 @@ import {
defaultTimeout, defaultTimeout,
userInfoEndpoint, userInfoEndpoint,
} from './constant.js'; } from './constant.js';
import type { KakaoConfig } from './types.js'; import type { KakaoConfig, AccessTokenResponse, UserInfoResponse, AuthResponse } from './types.js';
import { import {
accessTokenResponseGuard, accessTokenResponseGuard,
authResponseGuard, authResponseGuard,
@ -71,15 +72,18 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessTokenResponseGuard
} );
assert(
const { access_token: accessToken } = result.data; accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken }; return { accessToken };
}; };
@ -87,7 +91,11 @@ export const getAccessToken = async (
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data); const { code, redirectUri } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<KakaoConfig>(config, kakaoConfigGuard); validateConfig<KakaoConfig>(config, kakaoConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri }); const { accessToken } = await getAccessToken(config, { code, redirectUri });
@ -100,13 +108,11 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { id, kakao_account } = connectorDataParser<UserInfoResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); userInfoResponseGuard
} );
const { id, kakao_account } = result.data;
const { is_email_valid, email, profile } = kakao_account ?? { const { is_email_valid, email, profile } = kakao_account ?? {
is_email_valid: null, is_email_valid: null,
profile: null, profile: null,
@ -124,25 +130,16 @@ const getUserInfo =
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const getUserInfoErrorHandler = (error: unknown) => { const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;

View file

@ -38,3 +38,5 @@ export const authResponseGuard = z.object({
code: z.string(), code: z.string(),
redirectUri: z.string(), redirectUri: z.string(),
}); });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -1,9 +1,9 @@
import { got } from 'got'; import { got } from 'got';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { connectorDataParser, parseJsonObject } from '@logto/connector-kit';
import { defaultTimeout, scope } from './constant.js'; import { defaultTimeout, scope } from './constant.js';
import { accessTokenResponseGuard } from './types.js'; import { accessTokenResponseGuard, type AccessTokenResponse } from './types.js';
export type GrantAccessTokenParameters = { export type GrantAccessTokenParameters = {
tokenEndpoint: string; tokenEndpoint: string;
@ -32,11 +32,6 @@ export const grantAccessToken = async ({
}, },
}); });
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body)); const parsedBody = parseJsonObject(httpResponse.body);
return connectorDataParser<AccessTokenResponse>(parsedBody, accessTokenResponseGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
}; };

View file

@ -1,9 +1,9 @@
import { got } from 'got'; import { got } from 'got';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { connectorDataParser, parseJsonObject } from '@logto/connector-kit';
import { defaultTimeout, scope } from './constant.js'; import { defaultTimeout, scope } from './constant.js';
import { accessTokenResponseGuard } from './types.js'; import { accessTokenResponseGuard, type AccessTokenResponse } from './types.js';
export type GrantAccessTokenParameters = { export type GrantAccessTokenParameters = {
tokenEndpoint: string; tokenEndpoint: string;
@ -33,11 +33,6 @@ export const grantAccessToken = async ({
}, },
}); });
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body)); const parsedBody = parseJsonObject(httpResponse.body);
return connectorDataParser<AccessTokenResponse>(parsedBody, accessTokenResponseGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
}; };

View file

@ -30,10 +30,9 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Template not found for type: ${type}`,
`Template not found for type: ${type}` })
)
); );
await fs.writeFile( await fs.writeFile(

View file

@ -30,10 +30,9 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Template not found for type: ${type}`,
`Template not found for type: ${type}` })
)
); );
await fs.writeFile( await fs.writeFile(

View file

@ -30,10 +30,9 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Template not found for type: ${type}`,
`Template not found for type: ${type}` })
)
); );
await fs.writeFile( await fs.writeFile(

View file

@ -26,7 +26,7 @@ const getUserInfo: GetUserInfo = async (data) => {
const result = dataGuard.safeParse(data); const result = dataGuard.safeParse(data);
if (!result.success) { if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data)); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data });
} }
const { code, userId, ...rest } = result.data; const { code, userId, ...rest } = result.data;

View file

@ -60,7 +60,12 @@ describe('naver connector', () => {
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' }); .reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
await expect( await expect(
getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' }) getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); ).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: '',
message: 'accessToken is empty',
})
);
}); });
}); });
@ -116,7 +121,7 @@ describe('naver connector', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect( await expect(
connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn()) connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)); ).rejects.toThrow();
}); });
it('throws General error', async () => { it('throws General error', async () => {
@ -139,12 +144,7 @@ describe('naver connector', () => {
}, },
jest.fn() jest.fn()
) )
).rejects.toMatchError( ).rejects.toThrow();
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
}); });
it('throws unrecognized error', async () => { it('throws unrecognized error', async () => {

View file

@ -18,6 +18,7 @@ import {
ConnectorType, ConnectorType,
validateConfig, validateConfig,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -27,7 +28,7 @@ import {
defaultTimeout, defaultTimeout,
userInfoEndpoint, userInfoEndpoint,
} from './constant.js'; } from './constant.js';
import type { NaverConfig } from './types.js'; import type { NaverConfig, AccessTokenResponse, UserInfoResponse, AuthResponse } from './types.js';
import { import {
accessTokenResponseGuard, accessTokenResponseGuard,
authResponseGuard, authResponseGuard,
@ -71,15 +72,19 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
parsedBody,
accessTokenResponseGuard
);
if (!result.success) { assert(
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessToken,
} new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: accessToken,
const { access_token: accessToken } = result.data; message: 'accessToken is empty',
})
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); );
return { accessToken }; return { accessToken };
}; };
@ -87,7 +92,11 @@ export const getAccessToken = async (
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data); const { code, redirectUri } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<NaverConfig>(config, naverConfigGuard); validateConfig<NaverConfig>(config, naverConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri }); const { accessToken } = await getAccessToken(config, { code, redirectUri });
@ -100,13 +109,8 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const { response } = connectorDataParser<UserInfoResponse>(parsedBody, userInfoResponseGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { response } = result.data;
const { id, email, nickname, profile_image } = response; const { id, email, nickname, profile_image } = response;
return { return {
@ -120,25 +124,16 @@ const getUserInfo =
} }
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const getUserInfoErrorHandler = (error: unknown) => { const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;

View file

@ -37,3 +37,5 @@ export const authResponseGuard = z.object({
code: z.string(), code: z.string(),
redirectUri: z.string(), redirectUri: z.string(),
}); });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -76,7 +76,7 @@ const getUserInfo =
return userProfileMapping(parseJsonObject(httpResponse.body), parsedConfig.profileMap); return userProfileMapping(parseJsonObject(httpResponse.body), parsedConfig.profileMap);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); throw new ConnectorError(ConnectorErrorCodes.General, { data: error.response.body });
} }
throw error; throw error;

View file

@ -1,9 +1,15 @@
import { assert, pick } from '@silverhand/essentials'; import { pick } from '@silverhand/essentials';
import type { Response } from 'got'; import type { Response } from 'got';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys'; import snakecaseKeys from 'snakecase-keys';
import { type z } from 'zod';
import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; import {
ConnectorError,
ConnectorErrorCodes,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import qs from 'query-string'; import qs from 'query-string';
import { defaultTimeout } from './constant.js'; import { defaultTimeout } from './constant.js';
@ -12,6 +18,8 @@ import type {
TokenEndpointResponseType, TokenEndpointResponseType,
AccessTokenResponse, AccessTokenResponse,
ProfileMap, ProfileMap,
UserProfile,
AuthResponse,
} from './types.js'; } from './types.js';
import { authResponseGuard, accessTokenResponseGuard, userProfileGuard } from './types.js'; import { authResponseGuard, accessTokenResponseGuard, userProfileGuard } from './types.js';
@ -31,7 +39,7 @@ export const accessTokenRequester = async (
return await accessTokenResponseHandler(httpResponse, tokenEndpointResponseType); return await accessTokenResponseHandler(httpResponse, tokenEndpointResponseType);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); throw new ConnectorError(ConnectorErrorCodes.General, { data: error.response.body });
} }
throw error; throw error;
} }
@ -41,22 +49,13 @@ const accessTokenResponseHandler = async (
response: Response<string>, response: Response<string>,
tokenEndpointResponseType: TokenEndpointResponseType tokenEndpointResponseType: TokenEndpointResponseType
): Promise<AccessTokenResponse> => { ): Promise<AccessTokenResponse> => {
const result = accessTokenResponseGuard.safeParse( /**
tokenEndpointResponseType === 'json' ? parseJson(response.body) : qs.parse(response.body) * Why it works with qs.parse()?
); // Why it works with qs.parse() * Some social vendor (like GitHub) does not strictly follow the OAuth2 protocol.
*/
if (!result.success) { const parsedBody =
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); tokenEndpointResponseType === 'json' ? parseJson(response.body) : qs.parse(response.body);
} return connectorDataParser<AccessTokenResponse>(parsedBody, accessTokenResponseGuard);
assert(
result.data.access_token,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
message: 'Can not find `access_token` in token response!',
})
);
return result.data;
}; };
export const userProfileMapping = ( export const userProfileMapping = (
@ -74,24 +73,14 @@ export const userProfileMapping = (
.filter(([key, value]) => keyMap.get(key) && value) .filter(([key, value]) => keyMap.get(key) && value)
.map(([key, value]) => [keyMap.get(key), value]) .map(([key, value]) => [keyMap.get(key), value])
); );
return connectorDataParser<UserProfile, z.input<typeof userProfileGuard>>(
const result = userProfileGuard.safeParse(mappedUserProfile); mappedUserProfile,
userProfileGuard
if (!result.success) { );
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
}; };
export const getAccessToken = async (config: OauthConfig, data: unknown, redirectUri: string) => { export const getAccessToken = async (config: OauthConfig, data: unknown, redirectUri: string) => {
const result = authResponseGuard.safeParse(data); const { code } = connectorDataParser<AuthResponse>(data, authResponseGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, data);
}
const { code } = result.data;
const { customConfig, ...rest } = config; const { customConfig, ...rest } = config;

View file

@ -14,12 +14,13 @@ import {
ConnectorErrorCodes, ConnectorErrorCodes,
validateConfig, validateConfig,
ConnectorType, ConnectorType,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { generateStandardId } from '@logto/shared/universal'; import { generateStandardId } from '@logto/shared/universal';
import { createRemoteJWKSet, jwtVerify } from 'jose'; import { createRemoteJWKSet, jwtVerify } from 'jose';
import { defaultMetadata } from './constant.js'; import { defaultMetadata } from './constant.js';
import type { OidcConfig } from './types.js'; import type { OidcConfig, IdTokenProfileStandardClaims } from './types.js';
import { idTokenProfileStandardClaimsGuard, oidcConfigGuard } from './types.js'; import { idTokenProfileStandardClaimsGuard, oidcConfigGuard } from './types.js';
import { getIdToken } from './utils.js'; import { getIdToken } from './utils.js';
@ -98,11 +99,11 @@ const getUserInfo =
} }
); );
const result = idTokenProfileStandardClaimsGuard.safeParse(payload); const profile = connectorDataParser<IdTokenProfileStandardClaims>(
payload,
if (!result.success) { idTokenProfileStandardClaimsGuard,
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, result.error); ConnectorErrorCodes.SocialIdTokenInvalid
} );
const { const {
sub: id, sub: id,
@ -113,7 +114,7 @@ const getUserInfo =
phone, phone,
phone_verified, phone_verified,
nonce, nonce,
} = result.data; } = profile;
if (nonce) { if (nonce) {
// TODO @darcy: need to specify error code // TODO @darcy: need to specify error code
@ -121,6 +122,7 @@ const getUserInfo =
validationNonce, validationNonce,
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
message: 'Cannot find `nonce` in session storage.', message: 'Cannot find `nonce` in session storage.',
data: profile,
}) })
); );
@ -128,6 +130,7 @@ const getUserInfo =
validationNonce === nonce, validationNonce === nonce,
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, { new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, {
message: 'ID Token validation failed due to `nonce` mismatch.', message: 'ID Token validation failed due to `nonce` mismatch.',
data: profile,
}) })
); );
} }
@ -141,7 +144,7 @@ const getUserInfo =
}; };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); throw new ConnectorError(ConnectorErrorCodes.General, { data: error.response.body });
} }
throw error; throw error;

View file

@ -28,6 +28,8 @@ export const idTokenProfileStandardClaimsGuard = z.object({
nonce: z.string().nullish(), nonce: z.string().nullish(),
}); });
export type IdTokenProfileStandardClaims = z.infer<typeof idTokenProfileStandardClaimsGuard>;
export const userProfileGuard = z.object({ export const userProfileGuard = z.object({
id: z.preprocess(String, z.string()), id: z.preprocess(String, z.string()),
email: z.string().optional(), email: z.string().optional(),

View file

@ -3,10 +3,15 @@ import type { Response } from 'got';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys'; import snakecaseKeys from 'snakecase-keys';
import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; import {
ConnectorError,
ConnectorErrorCodes,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import { defaultTimeout } from './constant.js'; import { defaultTimeout } from './constant.js';
import type { AccessTokenResponse, OidcConfig } from './types.js'; import type { AccessTokenResponse, OidcConfig, AuthResponse } from './types.js';
import { accessTokenResponseGuard, delimiter, authResponseGuard } from './types.js'; import { accessTokenResponseGuard, delimiter, authResponseGuard } from './types.js';
export const accessTokenRequester = async ( export const accessTokenRequester = async (
@ -24,7 +29,7 @@ export const accessTokenRequester = async (
return await accessTokenResponseHandler(httpResponse); return await accessTokenResponseHandler(httpResponse);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); throw new ConnectorError(ConnectorErrorCodes.General, { data: error.response.body });
} }
throw error; throw error;
} }
@ -33,20 +38,20 @@ export const accessTokenRequester = async (
const accessTokenResponseHandler = async ( const accessTokenResponseHandler = async (
response: Response<string> response: Response<string>
): Promise<AccessTokenResponse> => { ): Promise<AccessTokenResponse> => {
const result = accessTokenResponseGuard.safeParse(parseJson(response.body)); const parsedBody = parseJson(response.body);
const accessTokenResponse = connectorDataParser<AccessTokenResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); accessTokenResponseGuard
} );
assert( assert(
result.data.access_token, accessTokenResponse.access_token,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
message: 'Missing `access_token` in token response!', message: 'Missing `access_token` in token response!',
data: accessTokenResponse,
}) })
); );
return result.data; return accessTokenResponse;
}; };
export const isIdTokenInResponseType = (responseType: string) => { export const isIdTokenInResponseType = (responseType: string) => {
@ -54,13 +59,11 @@ export const isIdTokenInResponseType = (responseType: string) => {
}; };
export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: string) => { export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: string) => {
const result = authResponseGuard.safeParse(data); const { code } = connectorDataParser<AuthResponse>(
data,
if (!result.success) { authResponseGuard,
throw new ConnectorError(ConnectorErrorCodes.General, data); ConnectorErrorCodes.General
} );
const { code } = result.data;
const { customConfig, ...rest } = config; const { customConfig, ...rest } = config;

View file

@ -91,7 +91,7 @@ const getAuthorizationUri =
return loginRequest.context; return loginRequest.context;
} catch (error: unknown) { } catch (error: unknown) {
throw new ConnectorError(ConnectorErrorCodes.General, String(error)); throw new ConnectorError(ConnectorErrorCodes.General, { data: error });
} }
}; };
@ -110,6 +110,10 @@ export const validateSamlAssertion =
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
message: message:
'Can not find `connectorFactoryId` from connector session match the fixed metadata id.', 'Can not find `connectorFactoryId` from connector session match the fixed metadata id.',
data: {
connectorFactoryId,
defaultMetadataId: defaultMetadata.id,
},
}) })
); );
@ -153,7 +157,10 @@ const getUserInfo =
const rawProfileParseResult = extractedRawProfileGuard.safeParse(extractedRawProfile); const rawProfileParseResult = extractedRawProfileGuard.safeParse(extractedRawProfile);
if (!rawProfileParseResult.success) { if (!rawProfileParseResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
zodError: rawProfileParseResult.error,
data: extractedRawProfile,
});
} }
const rawUserProfile = rawProfileParseResult.data; const rawUserProfile = rawProfileParseResult.data;

View file

@ -1,5 +1,10 @@
import type { SetSession } from '@logto/connector-kit'; import {
import { ConnectorError, ConnectorErrorCodes, socialUserInfoGuard } from '@logto/connector-kit'; ConnectorError,
ConnectorErrorCodes,
socialUserInfoGuard,
connectorDataParser,
} from '@logto/connector-kit';
import type { SetSession, SocialUserInfo } from '@logto/connector-kit';
import { XMLValidator } from 'fast-xml-parser'; import { XMLValidator } from 'fast-xml-parser';
import * as saml from 'samlify'; import * as saml from 'samlify';
@ -20,13 +25,7 @@ export const userProfileMapping = (
.map(([key, value]) => [keyMap.get(key), value]) .map(([key, value]) => [keyMap.get(key), value])
); );
const result = socialUserInfoGuard.safeParse(mappedUserProfile); return connectorDataParser<SocialUserInfo>(mappedUserProfile, socialUserInfoGuard);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
}; };
export const getUserInfoFromRawUserProfile = ( export const getUserInfoFromRawUserProfile = (
@ -34,13 +33,11 @@ export const getUserInfoFromRawUserProfile = (
keyMapping: ProfileMap keyMapping: ProfileMap
) => { ) => {
const userProfile = userProfileMapping(rawUserProfile, keyMapping); const userProfile = userProfileMapping(rawUserProfile, keyMapping);
const result = socialUserInfoGuard.safeParse(userProfile); return connectorDataParser<SocialUserInfo>(
userProfile,
if (!result.success) { socialUserInfoGuard,
throw new ConnectorError(ConnectorErrorCodes.General, result.error); ConnectorErrorCodes.General
} );
return result.data;
}; };
export const samlAssertionHandler = async ( export const samlAssertionHandler = async (
@ -106,6 +103,6 @@ export const samlAssertionHandler = async (
}, },
}); });
} catch (error: unknown) { } catch (error: unknown) {
throw new ConnectorError(ConnectorErrorCodes.General, String(error)); throw new ConnectorError(ConnectorErrorCodes.General, { data: error });
} }
}; };

View file

@ -35,10 +35,10 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Template not found for type: ${type}`,
`Template not found for type: ${type}` data: templates,
) })
); );
const toEmailData: EmailData[] = [{ email: to }]; const toEmailData: EmailData[] = [{ email: to }];
@ -78,13 +78,13 @@ const sendMessage =
assert( assert(
typeof rawBody === 'string', typeof rawBody === 'string',
new ConnectorError( new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
ConnectorErrorCodes.InvalidResponse, message: `Invalid response raw body type: ${typeof rawBody}`,
`Invalid response raw body type: ${typeof rawBody}` data: rawBody,
) })
); );
throw new ConnectorError(ConnectorErrorCodes.General, rawBody); throw new ConnectorError(ConnectorErrorCodes.General, { data: rawBody });
} }
throw error; throw error;

View file

@ -29,10 +29,10 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Template not found for type: ${type}`,
`Template not found for type: ${type}` data: config.templates,
) })
); );
const configOptions: SMTPTransport.Options = config; const configOptions: SMTPTransport.Options = config;
@ -57,10 +57,7 @@ const sendMessage =
try { try {
return await transporter.sendMail(mailOptions); return await transporter.sendMail(mailOptions);
} catch (error: unknown) { } catch (error: unknown) {
throw new ConnectorError( throw new ConnectorError(ConnectorErrorCodes.General, { data: error });
ConnectorErrorCodes.General,
error instanceof Error ? error.message : ''
);
} }
}; };
@ -73,10 +70,10 @@ const parseContents = (contents: string, contentType: ContextType) => {
return { html: contents }; return { html: contents };
} }
default: { default: {
throw new ConnectorError( throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
ConnectorErrorCodes.InvalidConfig, message: '`contentType` should be ContextType.',
'`contentType` should be ContextType.' data: contentType,
); });
} }
} }
}; };

View file

@ -24,7 +24,9 @@ function safeGetArray<T>(value: Array<T | undefined>, index: number): T {
assert( assert(
item, item,
new ConnectorError(ConnectorErrorCodes.General, `Cannot find item at index ${index}`) new ConnectorError(ConnectorErrorCodes.General, {
message: `Cannot find item at index ${index}`,
})
); );
return item; return item;
@ -40,10 +42,10 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Cannot find template for type: ${type}`,
`Cannot find template for type: ${type}` data: templates,
) })
); );
try { try {
@ -61,7 +63,7 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
if (isError) { if (isError) {
const { Response } = responseData; const { Response } = responseData;
const { Error } = Response; const { Error } = Response;
throw new ConnectorError(ConnectorErrorCodes.General, `${Error.Code}: ${Error.Message}`); throw new ConnectorError(ConnectorErrorCodes.General, { data: Error });
} }
const { const {
@ -72,10 +74,10 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
assert( assert(
Code.toLowerCase() === 'ok', Code.toLowerCase() === 'ok',
new ConnectorError( new ConnectorError(ConnectorErrorCodes.General, {
ConnectorErrorCodes.General, message: Message,
`${Code}: ${Message}, RequestId: ${RequestId}` data: { Code },
) })
); );
return httpResponse; return httpResponse;
@ -96,13 +98,12 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
const { Message, Code } = Error; const { Message, Code } = Error;
throw new ConnectorError(ConnectorErrorCodes.General, { throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: Message, message: Message,
Code, data: { Code },
...result,
}); });
} }
throw new ConnectorError(ConnectorErrorCodes.General, `Request error: ${message}`); throw new ConnectorError(ConnectorErrorCodes.General, { data: error });
} }
}; };
} }

View file

@ -29,10 +29,10 @@ const sendMessage =
assert( assert(
template, template,
new ConnectorError( new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
ConnectorErrorCodes.TemplateNotFound, message: `Cannot find template for type: ${type}`,
`Cannot find template for type: ${type}` data: templates,
) })
); );
const parameters: PublicParameters = { const parameters: PublicParameters = {
@ -60,13 +60,13 @@ const sendMessage =
} = error; } = error;
assert( assert(
typeof rawBody === 'string', typeof rawBody === 'string',
new ConnectorError( new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
ConnectorErrorCodes.InvalidResponse, message: `Invalid response raw body type: ${typeof rawBody}`,
`Invalid response raw body type: ${typeof rawBody}` data: rawBody,
) })
); );
throw new ConnectorError(ConnectorErrorCodes.General, rawBody); throw new ConnectorError(ConnectorErrorCodes.General, { data: rawBody });
} }
throw error; throw error;

View file

@ -67,7 +67,9 @@ describe('getAccessToken', () => {
.query(parameters) .query(parameters)
.reply(200, { errcode: 40_029, errmsg: 'invalid code' }); .reply(200, { errcode: 40_029, errmsg: 'invalid code' });
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError( await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: { errcode: 40_029, errmsg: 'invalid code' },
})
); );
}); });
@ -77,7 +79,9 @@ describe('getAccessToken', () => {
.query(true) .query(true)
.reply(200, { errcode: 40_163, errmsg: 'code been used' }); .reply(200, { errcode: 40_163, errmsg: 'code been used' });
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError( await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'code been used') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: { errcode: 40_163, errmsg: 'code been used' },
})
); );
}); });
@ -88,8 +92,7 @@ describe('getAccessToken', () => {
.reply(200, { errcode: -1, errmsg: 'system error' }); .reply(200, { errcode: -1, errmsg: 'system error' });
await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError( await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'system error', data: { errcode: -1, errmsg: 'system error' },
errcode: -1,
}) })
); );
}); });
@ -155,9 +158,7 @@ describe('getUserInfo', () => {
it('throws General error if code not provided in input', async () => { it('throws General error if code not provided in input', async () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
}); });
it('throws error if `openid` is missing', async () => { it('throws error if `openid` is missing', async () => {
@ -172,8 +173,10 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'missing openid', data: {
errcode: 41_009, errcode: 41_009,
errmsg: 'missing openid',
},
}) })
); );
}); });
@ -185,7 +188,9 @@ describe('getUserInfo', () => {
.reply(200, { errcode: 40_001, errmsg: 'invalid credential' }); .reply(200, { errcode: 40_001, errmsg: 'invalid credential' });
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid credential') new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: { errcode: 40_001, errmsg: 'invalid credential' },
})
); );
}); });
@ -203,8 +208,7 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'invalid openid', data: { errcode: 40_003, errmsg: 'invalid openid' },
errcode: 40_003,
}) })
); );
}); });
@ -212,8 +216,6 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => { it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401); nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
}); });
}); });

View file

@ -19,6 +19,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -34,6 +35,9 @@ import type {
GetAccessTokenErrorHandler, GetAccessTokenErrorHandler,
UserInfoResponseMessageParser, UserInfoResponseMessageParser,
WechatNativeConfig, WechatNativeConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js'; } from './types.js';
import { import {
wechatNativeConfigGuard, wechatNativeConfigGuard,
@ -73,16 +77,21 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const accessTokenResponse = connectorDataParser<AccessTokenResponse>(
parsedBody,
accessTokenResponseGuard
);
const { access_token: accessToken, openid } = accessTokenResponse;
if (!result.success) { getAccessTokenErrorHandler(accessTokenResponse);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); assert(
} accessToken && openid,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
const { access_token: accessToken, openid } = result.data; data: { accessToken, openid },
message: 'Access token or openid is missing.',
getAccessTokenErrorHandler(result.data); })
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse)); );
return { accessToken, openid }; return { accessToken, openid };
}; };
@ -90,7 +99,11 @@ export const getAccessToken = async (
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code } = await authorizationCallbackHandler(data); const { code } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<WechatNativeConfig>(config, wechatNativeConfigGuard); validateConfig<WechatNativeConfig>(config, wechatNativeConfigGuard);
const { accessToken, openid } = await getAccessToken(code, config); const { accessToken, openid } = await getAccessToken(code, config);
@ -101,18 +114,17 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const userInfoResponse = connectorDataParser<UserInfoResponse>(
if (!result.success) { parsedBody,
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); userInfoResponseGuard
} );
const { unionid, headimgurl, nickname } = userInfoResponse;
const { unionid, headimgurl, nickname } = result.data;
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}. // Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa. // These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty. // 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
userInfoResponseMessageParser(result.data); userInfoResponseMessageParser(userInfoResponse);
return { id: unionid ?? openid, avatar: headimgurl, name: nickname }; return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) { } catch (error: unknown) {
@ -122,55 +134,46 @@ const getUserInfo =
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html // See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => { const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken; const { errcode } = accessToken;
if (errcode) { if (errcode) {
assert( throw new ConnectorError(
!invalidAuthCodeErrcode.includes(errcode), invalidAuthCodeErrcode.includes(errcode)
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg) ? ConnectorErrorCodes.SocialAuthCodeInvalid
: ConnectorErrorCodes.General,
{ data: accessToken }
); );
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
} }
}; };
const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => { const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo; const { errcode } = userInfo;
if (errcode) { if (errcode) {
assert( throw new ConnectorError(
!invalidAccessTokenErrcode.includes(errcode), invalidAccessTokenErrcode.includes(errcode)
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg) ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: userInfo }
); );
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
} }
}; };
const getUserInfoErrorHandler = (error: unknown) => { const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createWechatNativeConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createWechatNativeConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -35,3 +35,5 @@ export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void; export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
export const authResponseGuard = z.object({ code: z.string() }); export const authResponseGuard = z.object({ code: z.string() });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -67,7 +67,9 @@ describe('getAccessToken', () => {
.query(parameters) .query(parameters)
.reply(200, { errcode: 40_029, errmsg: 'invalid code' }); .reply(200, { errcode: 40_029, errmsg: 'invalid code' });
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError( await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: { errcode: 40_029, errmsg: 'invalid code' },
})
); );
}); });
@ -77,7 +79,9 @@ describe('getAccessToken', () => {
.query(true) .query(true)
.reply(200, { errcode: 40_163, errmsg: 'code been used' }); .reply(200, { errcode: 40_163, errmsg: 'code been used' });
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError( await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'code been used') new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: { errcode: 40_163, errmsg: 'code been used' },
})
); );
}); });
@ -88,8 +92,7 @@ describe('getAccessToken', () => {
.reply(200, { errcode: -1, errmsg: 'system error' }); .reply(200, { errcode: -1, errmsg: 'system error' });
await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError( await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'system error', data: { errcode: -1, errmsg: 'system error' },
errcode: -1,
}) })
); );
}); });
@ -151,9 +154,7 @@ describe('getUserInfo', () => {
it('throws General error if code not provided in input', async () => { it('throws General error if code not provided in input', async () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
}); });
it('throws error if `openid` is missing', async () => { it('throws error if `openid` is missing', async () => {
@ -168,8 +169,10 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'missing openid', data: {
errcode: 41_009, errcode: 41_009,
errmsg: 'missing openid',
},
}) })
); );
}); });
@ -181,7 +184,9 @@ describe('getUserInfo', () => {
.reply(200, { errcode: 40_001, errmsg: 'invalid credential' }); .reply(200, { errcode: 40_001, errmsg: 'invalid credential' });
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid credential') new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: { errcode: 40_001, errmsg: 'invalid credential' },
})
); );
}); });
@ -199,8 +204,7 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, { new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'invalid openid', data: { errcode: 40_003, errmsg: 'invalid openid' },
errcode: 40_003,
}) })
); );
}); });
@ -208,8 +212,6 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => { it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401); nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toThrow();
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
}); });
}); });

View file

@ -19,6 +19,7 @@ import {
validateConfig, validateConfig,
ConnectorType, ConnectorType,
parseJson, parseJson,
connectorDataParser,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import {
@ -35,6 +36,9 @@ import type {
GetAccessTokenErrorHandler, GetAccessTokenErrorHandler,
UserInfoResponseMessageParser, UserInfoResponseMessageParser,
WechatConfig, WechatConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js'; } from './types.js';
import { import {
wechatConfigGuard, wechatConfigGuard,
@ -73,17 +77,22 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const accessTokenResponse = connectorDataParser<AccessTokenResponse>(
parsedBody,
accessTokenResponseGuard
);
if (!result.success) { const { access_token: accessToken, openid } = accessTokenResponse;
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { access_token: accessToken, openid } = result.data; getAccessTokenErrorHandler(accessTokenResponse);
assert(
getAccessTokenErrorHandler(result.data); accessToken && openid,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse)); data: { accessToken, openid },
message: 'Access token or openid is missing.',
})
);
return { accessToken, openid }; return { accessToken, openid };
}; };
@ -91,7 +100,11 @@ export const getAccessToken = async (
const getUserInfo = const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo => (getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => { async (data) => {
const { code } = await authorizationCallbackHandler(data); const { code } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig<WechatConfig>(config, wechatConfigGuard); validateConfig<WechatConfig>(config, wechatConfigGuard);
const { accessToken, openid } = await getAccessToken(code, config); const { accessToken, openid } = await getAccessToken(code, config);
@ -102,18 +115,18 @@ const getUserInfo =
timeout: { request: defaultTimeout }, timeout: { request: defaultTimeout },
}); });
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); const parsedBody = parseJson(httpResponse.body);
const userInfoResponse = connectorDataParser<UserInfoResponse>(
parsedBody,
userInfoResponseGuard
);
if (!result.success) { const { unionid, headimgurl, nickname } = userInfoResponse;
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { unionid, headimgurl, nickname } = result.data;
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}. // Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa. // These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty. // 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
userInfoResponseMessageParser(result.data); userInfoResponseMessageParser(userInfoResponse);
return { id: unionid ?? openid, avatar: headimgurl, name: nickname }; return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) { } catch (error: unknown) {
@ -123,55 +136,46 @@ const getUserInfo =
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html // See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => { const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken; const { errcode } = accessToken;
if (errcode) { if (errcode) {
assert( throw new ConnectorError(
!invalidAuthCodeErrcode.includes(errcode), invalidAuthCodeErrcode.includes(errcode)
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg) ? ConnectorErrorCodes.SocialAuthCodeInvalid
: ConnectorErrorCodes.General,
{ data: accessToken }
); );
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
} }
}; };
const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => { const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo; const { errcode } = userInfo;
if (errcode) { if (errcode) {
assert( throw new ConnectorError(
!invalidAccessTokenErrcode.includes(errcode), invalidAccessTokenErrcode.includes(errcode)
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg) ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: userInfo }
); );
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
} }
}; };
const getUserInfoErrorHandler = (error: unknown) => { const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response; const { statusCode } = error.response;
if (statusCode === 401) { throw new ConnectorError(
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); statusCode === 401
} ? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); { data: error.response }
);
} }
throw error; throw error;
}; };
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createWechatConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => { const createWechatConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,

View file

@ -35,3 +35,5 @@ export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void; export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
export const authResponseGuard = z.object({ code: z.string() }); export const authResponseGuard = z.object({ code: z.string() });
export type AuthResponse = z.infer<typeof authResponseGuard>;

View file

@ -88,7 +88,10 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type); const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type);
if (!messageTypeResult.success) { if (!messageTypeResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig); throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
zodError: messageTypeResult.error,
data: passcode.type,
});
} }
const response = await sendMessage({ const response = await sendMessage({

View file

@ -27,205 +27,174 @@ describe('koaConnectorErrorHandler middleware', () => {
it('Invalid Request Parameters', async () => { it('Invalid Request Parameters', async () => {
const message = 'Mock Invalid Request Parameters'; const message = 'Mock Invalid Request Parameters';
const error = new ConnectorError(ConnectorErrorCodes.InvalidRequestParameters, message); const error = new ConnectorError(ConnectorErrorCodes.InvalidRequestParameters, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.invalid_request_parameters', code: 'connector.invalid_request_parameters',
status: 400, status: 400,
}, })
{ message }
)
); );
}); });
it('Insufficient Request Parameters', async () => { it('Insufficient Request Parameters', async () => {
const message = 'Mock Insufficient Request Parameters'; const message = 'Mock Insufficient Request Parameters';
const error = new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters, message); const error = new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters, {
message,
});
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.insufficient_request_parameters', code: 'connector.insufficient_request_parameters',
status: 400, status: 400,
}, })
{ message }
)
); );
}); });
it('Invalid Config', async () => { it('Invalid Config', async () => {
const message = 'Mock Invalid Config'; const message = 'Mock Invalid Config';
const error = new ConnectorError(ConnectorErrorCodes.InvalidConfig, message); const error = new ConnectorError(ConnectorErrorCodes.InvalidConfig, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.invalid_config', code: 'connector.invalid_config',
status: 400, status: 400,
}, })
{ message }
)
); );
}); });
it('Invalid Response', async () => { it('Invalid Response', async () => {
const message = 'Mock Invalid Response'; const message = 'Mock Invalid Response';
const error = new ConnectorError(ConnectorErrorCodes.InvalidResponse, message); const error = new ConnectorError(ConnectorErrorCodes.InvalidResponse, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.invalid_response', code: 'connector.invalid_response',
status: 400, status: 400,
}, })
{ message }
)
); );
}); });
it('Template Not Found', async () => { it('Template Not Found', async () => {
const message = 'Mock Template Not Found'; const message = 'Mock Template Not Found';
const error = new ConnectorError(ConnectorErrorCodes.TemplateNotFound, message); const error = new ConnectorError(ConnectorErrorCodes.TemplateNotFound, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.template_not_found', code: 'connector.template_not_found',
status: 400, status: 400,
}, })
{ message }
)
); );
}); });
it('Social Auth Code Invalid', async () => { it('Social Auth Code Invalid', async () => {
const message = 'Mock Social Auth Code Invalid'; const message = 'Mock Social Auth Code Invalid';
const error = new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, message); const error = new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.social_auth_code_invalid', code: 'connector.social_auth_code_invalid',
status: 401, status: 401,
}, })
{ message }
)
); );
}); });
it('Social Access Token Invalid', async () => { it('Social Access Token Invalid', async () => {
const message = 'Mock Social Access Token Invalid'; const message = 'Mock Social Access Token Invalid';
const error = new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, message); const error = new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.social_invalid_access_token', code: 'connector.social_invalid_access_token',
status: 401, status: 401,
}, })
{ message }
)
); );
}); });
it('Social Id Token Invalid', async () => { it('Social Id Token Invalid', async () => {
const message = 'Mock Social Id Token Invalid'; const message = 'Mock Social Id Token Invalid';
const error = new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, message); const error = new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.social_invalid_id_token', code: 'connector.social_invalid_id_token',
status: 401, status: 401,
}, })
{ message }
)
); );
}); });
it('Authorization Failed', async () => { it('Authorization Failed', async () => {
const message = 'Mock Authorization Failed'; const message = 'Mock Authorization Failed';
const error = new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, message); const error = new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.authorization_failed', code: 'connector.authorization_failed',
status: 401, status: 401,
}, })
{ message }
)
); );
}); });
it('Rate Limit Exceeded', async () => { it('Rate Limit Exceeded', async () => {
const message = 'Mock Rate Limit Exceeded'; const message = 'Mock Rate Limit Exceeded';
const error = new ConnectorError(ConnectorErrorCodes.RateLimitExceeded, message); const error = new ConnectorError(ConnectorErrorCodes.RateLimitExceeded, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.rate_limit_exceeded', code: 'connector.rate_limit_exceeded',
status: 429, status: 429,
}, })
{ message }
)
); );
}); });
it('General connector errors with string type messages', async () => { it('General connector errors with string type messages', async () => {
const message = 'Mock General connector errors'; const message = 'Mock General connector errors';
const error = new ConnectorError(ConnectorErrorCodes.General, message); const error = new ConnectorError(ConnectorErrorCodes.General, { message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });
await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError( await expect(koaConnectorErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError( new RequestError({
{
code: 'connector.general', code: 'connector.general',
status: 400, status: 400,
}, })
{ message }
)
); );
}); });
it('General connector errors with message objects', async () => { it('General connector errors with message objects', async () => {
const message = { errorCode: 400, errorDescription: 'Mock General connector errors' }; const message = { errorCode: 400, errorDescription: 'Mock General connector errors' };
const error = new ConnectorError(ConnectorErrorCodes.General, message); const error = new ConnectorError(ConnectorErrorCodes.General, { data: message });
next.mockImplementationOnce(() => { next.mockImplementationOnce(() => {
throw error; throw error;
}); });

View file

@ -114,10 +114,10 @@ export default function authnRoutes<T extends AnonymousRouter>(
const samlAssertionParseResult = samlAssertionGuard.safeParse(body); const samlAssertionParseResult = samlAssertionGuard.safeParse(body);
if (!samlAssertionParseResult.success) { if (!samlAssertionParseResult.success) {
throw new ConnectorError( throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
ConnectorErrorCodes.InvalidResponse, zodError: samlAssertionParseResult.error,
samlAssertionParseResult.error data: body,
); });
} }
/** /**

View file

@ -1,4 +1,4 @@
import type { ZodType } from 'zod'; import type { ZodType, ZodTypeDef } from 'zod';
import { ConnectorError, ConnectorErrorCodes } from './types.js'; import { ConnectorError, ConnectorErrorCodes } from './types.js';
@ -8,7 +8,7 @@ export function validateConfig<T>(config: unknown, guard: ZodType): asserts conf
const result = guard.safeParse(config); const result = guard.safeParse(config);
if (!result.success) { if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, { zodError: result.error });
} }
} }
@ -20,7 +20,7 @@ export const parseJson = (
try { try {
return JSON.parse(jsonString); return JSON.parse(jsonString);
} catch { } catch {
throw new ConnectorError(errorCode, errorPayload ?? jsonString); throw new ConnectorError(errorCode, { data: errorPayload ?? jsonString });
} }
}; };
@ -28,10 +28,23 @@ export const parseJsonObject = (...args: Parameters<typeof parseJson>) => {
const parsed = parseJson(...args); const parsed = parseJson(...args);
if (!(parsed !== null && typeof parsed === 'object')) { if (!(parsed !== null && typeof parsed === 'object')) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsed); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data: parsed });
} }
return parsed; return parsed;
}; };
export const connectorDataParser = <T = unknown, U = T>(
data: unknown,
guard: ZodType<T, ZodTypeDef, U>,
errorCode: ConnectorErrorCodes = ConnectorErrorCodes.InvalidResponse
): T => {
const result = guard.safeParse(data);
if (!result.success) {
throw new ConnectorError(errorCode, { zodError: result.error, data });
}
return result.data;
};
export const mockSmsVerificationCodeFileName = 'logto_mock_verification_code_record.txt'; export const mockSmsVerificationCodeFileName = 'logto_mock_verification_code_record.txt';

View file

@ -1,5 +1,6 @@
import type { LanguageTag } from '@logto/language-kit'; import type { LanguageTag } from '@logto/language-kit';
import { isLanguageTag } from '@logto/language-kit'; import { isLanguageTag } from '@logto/language-kit';
import { conditionalArray } from '@silverhand/essentials';
import type { ZodType } from 'zod'; import type { ZodType } from 'zod';
import { z } from 'zod'; import { z } from 'zod';
@ -57,15 +58,45 @@ export enum ConnectorErrorCodes {
AuthorizationFailed = 'authorization_failed', AuthorizationFailed = 'authorization_failed',
} }
/**
* This is copied from logto/packages/core/src/errors/RequestError/index.ts, this
* function should be moved to @logto/shared but relies on `zod`, which is not available there.
* Manually copied for now.
*/
const formatZodError = ({ issues }: z.ZodError): string[] =>
issues.map((issue) => {
const base = `Error in key path "${issue.path.map(String).join('.')}": (${issue.code}) `;
if (issue.code === 'invalid_type') {
return base + `Expected ${issue.expected} but received ${issue.received}.`;
}
return base + issue.message;
});
export class ConnectorError extends Error { export class ConnectorError extends Error {
public code: ConnectorErrorCodes; public code: ConnectorErrorCodes;
public data: unknown; public data: unknown;
constructor(code: ConnectorErrorCodes, data?: unknown) { /**
const message = typeof data === 'string' ? data : 'Connector error occurred.'; * Should provide `zodError` when the expected data type is not met;
* `data` is the real data you receive.
*/
constructor(
code: ConnectorErrorCodes,
payload?: { message?: string; data?: unknown; zodError?: z.ZodError }
) {
const { message: additionalMessage, data, zodError } = payload ?? {};
const message = conditionalArray(
`Connector error occurred: ${code}`,
additionalMessage,
data && `Data: ${JSON.stringify(data)}`,
zodError && `ZodError: ${formatZodError(zodError).join(' ')}`
).join('\n');
super(message); super(message);
this.name = 'ConnectorError';
this.code = code; this.code = code;
this.data = typeof data === 'string' ? { message: data } : data; this.data = data;
} }
} }