0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -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>',
});
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
});
it('should throw when accessToken is empty', async () => {
@ -93,7 +91,12 @@ describe('getAccessToken', () => {
});
await expect(
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 () => {
@ -111,7 +114,13 @@ describe('getAccessToken', () => {
await expect(
getAccessToken('wrong_code', mockedAlipayNativeConfigWithValidPrivateKey)
).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(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).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(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).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())
).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Invalid parameter',
data: {
code: '40002',
msg: 'Invalid parameter',
sub_code: 'isv.invalid-parameter',
sub_msg: '参数无效',
},
})
);
});
@ -247,7 +272,9 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig });
await expect(
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 () => {

View file

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

View file

@ -47,7 +47,7 @@ export const alipayUserInfoShareResponseGuard = z.object({
sub_msg: z.string().optional(),
});
type AlipayUserInfoShareResponseGuard = z.infer<typeof alipayUserInfoShareResponseGuard>;
type AlipayUserInfoShareResponse = z.infer<typeof alipayUserInfoShareResponseGuard>;
export const userInfoResponseGuard = z.object({
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 ErrorHandler = (response: AlipayUserInfoShareResponseGuard) => void;
export type ErrorHandler = (response: AlipayUserInfoShareResponse) => void;

View file

@ -78,7 +78,12 @@ describe('getAccessToken', () => {
await expect(
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 () => {
@ -97,7 +102,13 @@ describe('getAccessToken', () => {
await expect(
getAccessToken('wrong_code', mockedAlipayConfigWithValidPrivateKey)
).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 () => {
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse, '{}')
);
await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
});
it('should throw SocialAccessTokenInvalid with code 20001', async () => {
@ -172,7 +181,14 @@ describe('getUserInfo', () => {
await expect(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).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(
connector.getUserInfo({ auth_code: 'wrong_code' }, jest.fn())
).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())
).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Invalid parameter',
data: {
code: '40002',
msg: 'Invalid parameter',
sub_code: 'isv.invalid-parameter',
sub_msg: '参数无效',
},
})
);
});
@ -239,7 +264,7 @@ describe('getUserInfo', () => {
});
const connector = await createConnector({ getConfig });
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,
ConnectorType,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import dayjs from 'dayjs';
@ -37,7 +38,7 @@ import {
invalidAccessTokenCode,
invalidAccessTokenSubCode,
} 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 { signingParameters } from './utils.js';
@ -80,22 +81,22 @@ export const getAccessToken = async (code: string, config: AlipayConfig) => {
timeout: { request: defaultTimeout },
});
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
const parsedBody = parseJson(httpResponse.body);
const { error_response, alipay_system_oauth_token_response } =
connectorDataParser<AccessTokenResponse>(parsedBody, accessTokenResponseGuard);
assert(
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;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
assert(
accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken };
};
@ -103,7 +104,7 @@ export const getAccessToken = async (code: string, config: AlipayConfig) => {
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
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);
validateConfig<AlipayConfig>(config, alipayConfigGuard);
@ -111,7 +112,9 @@ const getUserInfo =
assert(
accessToken && config,
new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters)
new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters, {
message: 'access token or config is missing.',
})
);
const initSearchParameters = {
@ -133,57 +136,41 @@ const getUserInfo =
const { body: rawBody } = httpResponse;
const result = userInfoResponseGuard.safeParse(parseJson(rawBody));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { alipay_user_info_share_response } = result.data;
const parsedBody = parseJson(rawBody);
const { alipay_user_info_share_response } = connectorDataParser<UserInfoResponse>(
parsedBody,
userInfoResponseGuard
);
errorHandler(alipay_user_info_share_response);
const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response;
if (!id) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
message: 'user id is missing.',
});
}
return { id, avatar, name };
};
const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
const payload = { code, msg, sub_code, sub_msg };
if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, { data: payload });
}
if (sub_code) {
assert(
!invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg)
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { data: payload })
);
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: msg,
code,
sub_code,
sub_msg,
});
throw new ConnectorError(ConnectorErrorCodes.General, { data: payload });
}
};
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 }) => {
return {
metadata: defaultMetadata,

View file

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

View file

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

View file

@ -64,9 +64,7 @@ describe('getUserInfo', () => {
it('should throw if id token is missing', async () => {
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
});
it('should throw if verify id token failed', async () => {
@ -76,7 +74,7 @@ describe('getUserInfo', () => {
});
const connector = await createConnector({ getConfig });
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 });
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,
validateConfig,
ConnectorType,
connectorDataParser,
} from '@logto/connector-kit';
import { generateStandardId } from '@logto/shared/universal';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { scope, defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant.js';
import type { AppleConfig } from './types.js';
import { appleConfigGuard, dataGuard } from './types.js';
import type { AppleConfig, AuthorizationData } from './types.js';
import { appleConfigGuard, authorizationDataGuard } from './types.js';
const generateNonce = () => generateStandardId();
@ -56,10 +57,16 @@ const getAuthorizationUri =
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => {
const { id_token: idToken } = await authorizationCallbackHandler(data);
const { id_token: idToken } = connectorDataParser<AuthorizationData>(
data,
authorizationDataGuard,
ConnectorErrorCodes.General
);
if (!idToken) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, {
message: 'IdToken is not presented.',
});
}
const config = await getConfig(defaultMetadata.id);
@ -79,6 +86,7 @@ const getUserInfo =
getSession,
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
message: "'getSession' is not implemented.",
data: payload,
})
);
const { nonce: validationNonce } = await getSession();
@ -87,6 +95,7 @@ const getUserInfo =
validationNonce,
new ConnectorError(ConnectorErrorCodes.General, {
message: "'nonce' not presented in session storage.",
data: payload,
})
);
@ -94,32 +103,26 @@ const getUserInfo =
validationNonce === payload.nonce,
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, {
message: "IdToken validation failed due to 'nonce' mismatch.",
data: payload,
})
);
}
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 {
id: payload.sub,
};
} 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 }) => {
return {
metadata: defaultMetadata,

View file

@ -7,6 +7,8 @@ export const appleConfigGuard = z.object({
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
export const dataGuard = z.object({
export const authorizationDataGuard = z.object({
id_token: z.string(),
});
export type AuthorizationData = z.infer<typeof authorizationDataGuard>;

View file

@ -31,10 +31,10 @@ const sendMessage =
assert(
template,
new ConnectorError(
ConnectorErrorCodes.TemplateNotFound,
`Cannot find template for type: ${type}`
)
new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
message: `Cannot find template for type: ${type}`,
data: templates,
})
);
const client: SESv2Client = makeClient(accessKeyId, accessKeySecret, region);
@ -46,13 +46,13 @@ const sendMessage =
assert(
response.$metadata.httpStatusCode === 200,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, response)
new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data: response })
);
return response.MessageId;
} catch (error: unknown) {
if (error instanceof SESv2ServiceException) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, error.message);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data: 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 path from 'node:path';
@ -17,10 +17,16 @@ import {
validateConfig,
ConnectorType,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import { scopes, defaultMetadata, defaultTimeout, graphAPIEndpoint } from './constant.js';
import type { AzureADConfig } from './types.js';
import type {
AzureADConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js';
import {
azureADConfigGuard,
accessTokenResponseGuard,
@ -85,16 +91,17 @@ const getAccessToken = async (config: AzureADConfig, code: string, redirectUri:
});
const authResult = await clientApplication.acquireTokenByCode(codeRequest);
const result = accessTokenResponseGuard.safeParse(authResult);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
const { accessToken } = connectorDataParser<AccessTokenResponse>(
authResult,
accessTokenResponseGuard
);
assert(
accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken };
};
@ -102,7 +109,11 @@ const getAccessToken = async (config: AzureADConfig, code: string, redirectUri:
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
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.
const config = await getConfig(defaultMetadata.id);
@ -118,13 +129,11 @@ const getUserInfo =
timeout: { request: defaultTimeout },
});
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { id, mail, displayName } = result.data;
const parsedBody = parseJson(httpResponse.body);
const { id, mail, displayName } = connectorDataParser<UserInfoResponse>(
parsedBody,
userInfoResponseGuard
);
return {
id,
@ -133,29 +142,20 @@ const getUserInfo =
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const { statusCode } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
throw new ConnectorError(
statusCode === 401
? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: error.response }
);
}
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 }) => {
return {
metadata: defaultMetadata,

View file

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

View file

@ -66,7 +66,12 @@ describe('Discord connector', () => {
await expect(
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 });
await expect(
connector.getUserInfo({ code: 'code', redirectUri: 'dummyRedirectUri' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
).rejects.toThrow();
});
it('throws unrecognized error', async () => {

View file

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

View file

@ -86,7 +86,12 @@ describe('Facebook connector', () => {
await expect(
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 });
await expect(
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 () => {
@ -171,7 +176,14 @@ describe('Facebook connector', () => {
jest.fn()
)
).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(
new ConnectorError(ConnectorErrorCodes.General, {
data: {
error: 'general_error',
error_code: 200,
errorDescription: 'General error encountered.',
error_description: 'General error encountered.',
error_reason: 'user_denied',
},
})
);
});

View file

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

View file

@ -74,7 +74,9 @@ describe('getAccessToken', () => {
await expect(
getAccessToken('code', '123', '123', 'http://localhost:3000')
).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(
getAccessToken('code', '123', '123', 'http://localhost:3000')
).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 () => {
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse, '{}')
);
await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
});
it('should throw SocialAccessTokenInvalid with code invalid_token', async () => {
@ -159,7 +164,12 @@ describe('getUserInfo', () => {
await expect(
connector.getUserInfo({ code: 'error_code', redirectUri: 'http://localhost:3000' }, jest.fn())
).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 });
await expect(
connector.getUserInfo({ code: 'code', redirectUri: 'http://localhost:3000' }, jest.fn())
).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse, 'invalid user response')
);
).rejects.toThrow();
});
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 type {
@ -14,6 +14,7 @@ import {
ConnectorPlatform,
ConnectorType,
validateConfig,
connectorDataParser,
} from '@logto/connector-kit';
import {
@ -22,7 +23,13 @@ import {
defaultMetadata,
userInfoEndpoint,
} from './constant.js';
import type { FeishuConfig } from './types.js';
import type {
FeishuConfig,
FeishuAuthCode,
FeishuAccessTokenResponse,
FeishuErrorResponse,
FeishuUserInfoResponse,
} from './types.js';
import {
feishuAccessTokenResponse,
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(
code: string,
appId: string,
@ -88,44 +85,40 @@ export async function getAccessToken(
responseType: 'json',
});
const result = feishuAccessTokenResponse.safeParse(response.body);
assert(
result.success,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(response.body))
const { access_token: accessToken } = connectorDataParser<FeishuAccessTokenResponse>(
response.body,
feishuAccessTokenResponse
);
if (result.data.access_token.length === 0) {
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'access_token is empty');
if (accessToken.length === 0) {
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
message: 'access_token is empty',
});
}
return { accessToken: result.data.access_token };
return { accessToken };
} catch (error: unknown) {
if (error instanceof ConnectorError) {
throw error;
}
if (error instanceof HTTPError) {
const result = feishuErrorResponse.safeParse(error.response.body);
assert(
result.success,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(error.response.body))
);
throw new ConnectorError(
ConnectorErrorCodes.SocialAuthCodeInvalid,
result.data.error_description
const errorResponse = connectorDataParser<FeishuErrorResponse>(
error.response.body,
feishuErrorResponse
);
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, { data: errorResponse });
}
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Failed to get access token',
message: 'Failed to get access token',
});
}
}
export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo {
return async function (data) {
const { code, redirectUri } = await authorizationCallbackHandler(data);
const { code, redirectUri } = connectorDataParser<FeishuAuthCode>(data, feishuAuthCodeGuard);
const config = await getConfig(defaultMetadata.id);
validateConfig<FeishuConfig>(config, feishuConfigGuard);
@ -139,13 +132,14 @@ export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo {
responseType: 'json',
});
const result = feishuUserInfoResponse.safeParse(response.body);
assert(
result.success,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, `invalid user response`)
);
const { sub, user_id, name, email, avatar_url: avatar, mobile } = result.data;
const {
sub,
user_id,
name,
email,
avatar_url: avatar,
mobile,
} = connectorDataParser<FeishuUserInfoResponse>(response.body, feishuUserInfoResponse);
return {
id: sub,
@ -161,24 +155,17 @@ export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo {
}
if (error instanceof HTTPError) {
const result = feishuErrorResponse.safeParse(error.response.body);
assert(
result.success,
new ConnectorError(
ConnectorErrorCodes.InvalidResponse,
JSON.stringify(error.response.body)
)
);
throw new ConnectorError(
ConnectorErrorCodes.SocialAccessTokenInvalid,
result.data.error_description
const errorResponse = connectorDataParser<FeishuErrorResponse>(
error.response.body,
feishuErrorResponse
);
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, {
data: errorResponse,
});
}
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(),
});
export type FeishuAuthCode = z.infer<typeof feishuAuthCodeGuard>;
export const feishuErrorResponse = z.object({
error: z.string(),
error_description: z.string().optional(),
});
export type FeishuErrorResponse = z.infer<typeof feishuErrorResponse>;
export const feishuAccessTokenResponse = z.object({
access_token: z.string(),
token_type: z.string(),
@ -25,6 +29,8 @@ export const feishuAccessTokenResponse = z.object({
refresh_expires_in: z.number().optional(),
});
export type FeishuAccessTokenResponse = z.infer<typeof feishuAccessTokenResponse>;
export const feishuUserInfoResponse = z.object({
sub: z.string(),
name: z.string(),
@ -42,3 +48,5 @@ export const feishuUserInfoResponse = z.object({
employee_no: z.string().nullish(),
mobile: z.string().nullish(),
});
export type FeishuUserInfoResponse = z.infer<typeof feishuUserInfoResponse>;

View file

@ -61,7 +61,10 @@ describe('getAccessToken', () => {
.post('')
.reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' }));
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 () => {
nock(userInfoEndpoint).get('').reply(401);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toThrow();
});
it('throws AuthorizationFailed error if error is access_denied', async () => {
@ -143,10 +144,14 @@ describe('getUserInfo', () => {
jest.fn()
)
).rejects.toMatchError(
new ConnectorError(
ConnectorErrorCodes.AuthorizationFailed,
'The user has denied your application access.'
)
new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, {
data: {
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()
)
).rejects.toMatchError(
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
).rejects.toThrow();
});
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 type {
@ -14,6 +14,7 @@ import {
validateConfig,
ConnectorType,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import qs from 'query-string';
@ -25,7 +26,7 @@ import {
defaultMetadata,
defaultTimeout,
} from './constant.js';
import type { GithubConfig } from './types.js';
import type { GithubConfig, AccessTokenResponse, UserInfoResponse } from './types.js';
import {
authorizationCallbackErrorGuard,
githubConfigGuard,
@ -59,20 +60,18 @@ const authorizationCallbackHandler = async (parameterObject: unknown) => {
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
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, {
error,
errorDescription: error_description,
error_uri,
data: parameterObject,
zodError: parsedError.error,
});
}
throw new ConnectorError(
parsedError.data.error === 'access_denied'
? ConnectorErrorCodes.AuthorizationFailed
: ConnectorErrorCodes.General,
{ data: parsedError.data }
);
};
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 },
});
const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { access_token: accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
const parsedBody = qs.parse(httpResponse.body);
const { access_token: accessToken } = connectorDataParser<AccessTokenResponse>(
parsedBody,
accessTokenResponseGuard
);
assert(
accessToken,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, {
data: accessToken,
message: 'accessToken is empty',
})
);
return { accessToken };
};
@ -118,13 +119,13 @@ const getUserInfo =
timeout: { request: defaultTimeout },
});
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { id, avatar_url: avatar, email, name } = result.data;
const parsedBody = parseJson(httpResponse.body);
const {
id,
avatar_url: avatar,
email,
name,
} = connectorDataParser<UserInfoResponse>(parsedBody, userInfoResponseGuard);
return {
id: String(id),
@ -134,13 +135,14 @@ const getUserInfo =
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const { statusCode } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
throw new ConnectorError(
statusCode === 401
? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: error.response }
);
}
throw error;

View file

@ -60,7 +60,12 @@ describe('google connector', () => {
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
await expect(
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 });
await expect(
connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
).rejects.toThrow();
});
it('throws General error', async () => {
@ -133,12 +138,7 @@ describe('google connector', () => {
},
jest.fn()
)
).rejects.toMatchError(
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
).rejects.toThrow();
});
it('throws unrecognized error', async () => {

View file

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

View file

@ -33,3 +33,5 @@ export const authResponseGuard = z.object({
code: 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' });
await expect(
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 });
await expect(
connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
).rejects.toThrow();
});
it('throws General error', async () => {
@ -137,12 +142,7 @@ describe('kakao connector', () => {
},
jest.fn()
)
).rejects.toMatchError(
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
).rejects.toThrow();
});
it('throws unrecognized error', async () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,7 +26,7 @@ const getUserInfo: GetUserInfo = async (data) => {
const result = dataGuard.safeParse(data);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data));
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, { 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' });
await expect(
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 });
await expect(
connector.getUserInfo({ code: 'code', redirectUri: '' }, jest.fn())
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
).rejects.toThrow();
});
it('throws General error', async () => {
@ -139,12 +144,7 @@ describe('naver connector', () => {
},
jest.fn()
)
).rejects.toMatchError(
new ConnectorError(
ConnectorErrorCodes.General,
'{"error":"general_error","error_description":"General error encountered."}'
)
);
).rejects.toThrow();
});
it('throws unrecognized error', async () => {

View file

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

View file

@ -37,3 +37,5 @@ export const authResponseGuard = z.object({
code: 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);
} catch (error: unknown) {
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;

View file

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

View file

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

View file

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

View file

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

View file

@ -91,7 +91,7 @@ const getAuthorizationUri =
return loginRequest.context;
} 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, {
message:
'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);
if (!rawProfileParseResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, rawProfileParseResult.error);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
zodError: rawProfileParseResult.error,
data: extractedRawProfile,
});
}
const rawUserProfile = rawProfileParseResult.data;

View file

@ -1,5 +1,10 @@
import type { SetSession } from '@logto/connector-kit';
import { ConnectorError, ConnectorErrorCodes, socialUserInfoGuard } from '@logto/connector-kit';
import {
ConnectorError,
ConnectorErrorCodes,
socialUserInfoGuard,
connectorDataParser,
} from '@logto/connector-kit';
import type { SetSession, SocialUserInfo } from '@logto/connector-kit';
import { XMLValidator } from 'fast-xml-parser';
import * as saml from 'samlify';
@ -20,13 +25,7 @@ export const userProfileMapping = (
.map(([key, value]) => [keyMap.get(key), value])
);
const result = socialUserInfoGuard.safeParse(mappedUserProfile);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
return connectorDataParser<SocialUserInfo>(mappedUserProfile, socialUserInfoGuard);
};
export const getUserInfoFromRawUserProfile = (
@ -34,13 +33,11 @@ export const getUserInfoFromRawUserProfile = (
keyMapping: ProfileMap
) => {
const userProfile = userProfileMapping(rawUserProfile, keyMapping);
const result = socialUserInfoGuard.safeParse(userProfile);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, result.error);
}
return result.data;
return connectorDataParser<SocialUserInfo>(
userProfile,
socialUserInfoGuard,
ConnectorErrorCodes.General
);
};
export const samlAssertionHandler = async (
@ -106,6 +103,6 @@ export const samlAssertionHandler = async (
},
});
} 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(
template,
new ConnectorError(
ConnectorErrorCodes.TemplateNotFound,
`Template not found for type: ${type}`
)
new ConnectorError(ConnectorErrorCodes.TemplateNotFound, {
message: `Template not found for type: ${type}`,
data: templates,
})
);
const toEmailData: EmailData[] = [{ email: to }];
@ -78,13 +78,13 @@ const sendMessage =
assert(
typeof rawBody === 'string',
new ConnectorError(
ConnectorErrorCodes.InvalidResponse,
`Invalid response raw body type: ${typeof rawBody}`
)
new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
message: `Invalid response raw body type: ${typeof rawBody}`,
data: rawBody,
})
);
throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
throw new ConnectorError(ConnectorErrorCodes.General, { data: rawBody });
}
throw error;

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ import {
validateConfig,
ConnectorType,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import {
@ -34,6 +35,9 @@ import type {
GetAccessTokenErrorHandler,
UserInfoResponseMessageParser,
WechatNativeConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js';
import {
wechatNativeConfigGuard,
@ -73,16 +77,21 @@ export const getAccessToken = async (
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) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { access_token: accessToken, openid } = result.data;
getAccessTokenErrorHandler(result.data);
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
getAccessTokenErrorHandler(accessTokenResponse);
assert(
accessToken && openid,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
data: { accessToken, openid },
message: 'Access token or openid is missing.',
})
);
return { accessToken, openid };
};
@ -90,7 +99,11 @@ export const getAccessToken = async (
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code } = await authorizationCallbackHandler(data);
const { code } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id);
validateConfig<WechatNativeConfig>(config, wechatNativeConfigGuard);
const { accessToken, openid } = await getAccessToken(code, config);
@ -101,18 +114,17 @@ const getUserInfo =
timeout: { request: defaultTimeout },
});
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { unionid, headimgurl, nickname } = result.data;
const parsedBody = parseJson(httpResponse.body);
const userInfoResponse = connectorDataParser<UserInfoResponse>(
parsedBody,
userInfoResponseGuard
);
const { unionid, headimgurl, nickname } = userInfoResponse;
// 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.
// '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 };
} catch (error: unknown) {
@ -122,55 +134,46 @@ const getUserInfo =
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
const { errcode } = accessToken;
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
throw new ConnectorError(
invalidAuthCodeErrcode.includes(errcode)
? ConnectorErrorCodes.SocialAuthCodeInvalid
: ConnectorErrorCodes.General,
{ data: accessToken }
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
const { errcode } = userInfo;
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
throw new ConnectorError(
invalidAccessTokenErrcode.includes(errcode)
? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: userInfo }
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const { statusCode } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
throw new ConnectorError(
statusCode === 401
? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: error.response }
);
}
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 }) => {
return {
metadata: defaultMetadata,

View file

@ -35,3 +35,5 @@ export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
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)
.reply(200, { errcode: 40_029, errmsg: 'invalid code' });
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)
.reply(200, { errcode: 40_163, errmsg: 'code been used' });
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' });
await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'system error',
errcode: -1,
data: { errcode: -1, errmsg: 'system error' },
})
);
});
@ -151,9 +154,7 @@ describe('getUserInfo', () => {
it('throws General error if code not provided in input', async () => {
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
await expect(connector.getUserInfo({}, jest.fn())).rejects.toThrow();
});
it('throws error if `openid` is missing', async () => {
@ -168,8 +169,10 @@ describe('getUserInfo', () => {
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'missing openid',
data: {
errcode: 41_009,
errmsg: 'missing openid',
},
})
);
});
@ -181,7 +184,9 @@ describe('getUserInfo', () => {
.reply(200, { errcode: 40_001, errmsg: 'invalid credential' });
const connector = await createConnector({ getConfig });
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 });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'invalid openid',
errcode: 40_003,
data: { errcode: 40_003, errmsg: 'invalid openid' },
})
);
});
@ -208,8 +212,6 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toThrow();
});
});

View file

@ -19,6 +19,7 @@ import {
validateConfig,
ConnectorType,
parseJson,
connectorDataParser,
} from '@logto/connector-kit';
import {
@ -35,6 +36,9 @@ import type {
GetAccessTokenErrorHandler,
UserInfoResponseMessageParser,
WechatConfig,
AccessTokenResponse,
UserInfoResponse,
AuthResponse,
} from './types.js';
import {
wechatConfigGuard,
@ -73,17 +77,22 @@ export const getAccessToken = async (
timeout: { request: defaultTimeout },
});
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body));
const parsedBody = parseJson(httpResponse.body);
const accessTokenResponse = connectorDataParser<AccessTokenResponse>(
parsedBody,
accessTokenResponseGuard
);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { access_token: accessToken, openid } = accessTokenResponse;
const { access_token: accessToken, openid } = result.data;
getAccessTokenErrorHandler(result.data);
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
getAccessTokenErrorHandler(accessTokenResponse);
assert(
accessToken && openid,
new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
data: { accessToken, openid },
message: 'Access token or openid is missing.',
})
);
return { accessToken, openid };
};
@ -91,7 +100,11 @@ export const getAccessToken = async (
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code } = await authorizationCallbackHandler(data);
const { code } = connectorDataParser<AuthResponse>(
data,
authResponseGuard,
ConnectorErrorCodes.General
);
const config = await getConfig(defaultMetadata.id);
validateConfig<WechatConfig>(config, wechatConfigGuard);
const { accessToken, openid } = await getAccessToken(code, config);
@ -102,18 +115,18 @@ const getUserInfo =
timeout: { request: defaultTimeout },
});
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body));
const parsedBody = parseJson(httpResponse.body);
const userInfoResponse = connectorDataParser<UserInfoResponse>(
parsedBody,
userInfoResponseGuard
);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { unionid, headimgurl, nickname } = result.data;
const { unionid, headimgurl, nickname } = userInfoResponse;
// 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.
// '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 };
} catch (error: unknown) {
@ -123,55 +136,46 @@ const getUserInfo =
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
const { errcode } = accessToken;
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
throw new ConnectorError(
invalidAuthCodeErrcode.includes(errcode)
? ConnectorErrorCodes.SocialAuthCodeInvalid
: ConnectorErrorCodes.General,
{ data: accessToken }
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
const { errcode } = userInfo;
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
throw new ConnectorError(
invalidAccessTokenErrcode.includes(errcode)
? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: userInfo }
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const { statusCode } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
throw new ConnectorError(
statusCode === 401
? ConnectorErrorCodes.SocialAccessTokenInvalid
: ConnectorErrorCodes.General,
{ data: error.response }
);
}
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 }) => {
return {
metadata: defaultMetadata,

View file

@ -35,3 +35,5 @@ export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
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);
if (!messageTypeResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
zodError: messageTypeResult.error,
data: passcode.type,
});
}
const response = await sendMessage({

View file

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

View file

@ -114,10 +114,10 @@ export default function authnRoutes<T extends AnonymousRouter>(
const samlAssertionParseResult = samlAssertionGuard.safeParse(body);
if (!samlAssertionParseResult.success) {
throw new ConnectorError(
ConnectorErrorCodes.InvalidResponse,
samlAssertionParseResult.error
);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
zodError: 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';
@ -8,7 +8,7 @@ export function validateConfig<T>(config: unknown, guard: ZodType): asserts conf
const result = guard.safeParse(config);
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 {
return JSON.parse(jsonString);
} 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);
if (!(parsed !== null && typeof parsed === 'object')) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsed);
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, { data: 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';

View file

@ -1,5 +1,6 @@
import type { LanguageTag } from '@logto/language-kit';
import { isLanguageTag } from '@logto/language-kit';
import { conditionalArray } from '@silverhand/essentials';
import type { ZodType } from 'zod';
import { z } from 'zod';
@ -57,15 +58,45 @@ export enum ConnectorErrorCodes {
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 {
public code: ConnectorErrorCodes;
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);
this.name = 'ConnectorError';
this.code = code;
this.data = typeof data === 'string' ? { message: data } : data;
this.data = data;
}
}