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:
parent
8178d61eca
commit
75ab0411bf
61 changed files with 1037 additions and 913 deletions
35
.changeset/rare-llamas-share.md
Normal file
35
.changeset/rare-llamas-share.md
Normal 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.
|
|
@ -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',
|
||||
code: '40002',
|
||||
sub_code: 'isv.invalid-parameter',
|
||||
sub_msg: '参数无效',
|
||||
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 () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
code: '40002',
|
||||
sub_code: 'isv.invalid-parameter',
|
||||
sub_msg: '参数无效',
|
||||
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.' })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -37,3 +37,5 @@ export const authResponseGuard = z.object({
|
|||
code: z.string(),
|
||||
redirectUri: z.string(),
|
||||
});
|
||||
|
||||
export type AuthResponse = z.infer<typeof authResponseGuard>;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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, {
|
||||
error: 'general_error',
|
||||
error_code: 200,
|
||||
errorDescription: 'General error encountered.',
|
||||
error_reason: 'user_denied',
|
||||
data: {
|
||||
error: 'general_error',
|
||||
error_code: 200,
|
||||
error_description: 'General error encountered.',
|
||||
error_reason: 'user_denied',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
|
||||
data: parameterObject,
|
||||
zodError: parsedError.error,
|
||||
});
|
||||
}
|
||||
|
||||
const { error, error_code, error_description, error_reason } = parsedError.data;
|
||||
|
||||
if (error === 'access_denied') {
|
||||
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
|
||||
}
|
||||
|
||||
throw new ConnectorError(ConnectorErrorCodes.General, {
|
||||
error,
|
||||
error_code,
|
||||
errorDescription: error_description,
|
||||
error_reason,
|
||||
});
|
||||
throw new ConnectorError(
|
||||
parsedError.data.error === 'access_denied'
|
||||
? ConnectorErrorCodes.AuthorizationFailed
|
||||
: ConnectorErrorCodes.General,
|
||||
{ data: parsedError.data }
|
||||
);
|
||||
};
|
||||
|
||||
const createFacebookConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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));
|
||||
throw new ConnectorError(ConnectorErrorCodes.General, {
|
||||
data: parameterObject,
|
||||
zodError: parsedError.error,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -33,3 +33,5 @@ export const authResponseGuard = z.object({
|
|||
code: z.string(),
|
||||
redirectUri: z.string(),
|
||||
});
|
||||
|
||||
export type AuthResponse = z.infer<typeof authResponseGuard>;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -38,3 +38,5 @@ export const authResponseGuard = z.object({
|
|||
code: z.string(),
|
||||
redirectUri: z.string(),
|
||||
});
|
||||
|
||||
export type AuthResponse = z.infer<typeof authResponseGuard>;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -37,3 +37,5 @@ export const authResponseGuard = z.object({
|
|||
code: z.string(),
|
||||
redirectUri: z.string(),
|
||||
});
|
||||
|
||||
export type AuthResponse = z.infer<typeof authResponseGuard>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
errcode: 41_009,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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',
|
||||
errcode: 41_009,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
code: 'connector.invalid_request_parameters',
|
||||
status: 400,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.invalid_request_parameters',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.insufficient_request_parameters',
|
||||
status: 400,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.insufficient_request_parameters',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.invalid_config',
|
||||
status: 400,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.invalid_config',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.invalid_response',
|
||||
status: 400,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.invalid_response',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.template_not_found',
|
||||
status: 400,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.template_not_found',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.social_auth_code_invalid',
|
||||
status: 401,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.social_auth_code_invalid',
|
||||
status: 401,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.social_invalid_access_token',
|
||||
status: 401,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.social_invalid_access_token',
|
||||
status: 401,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.social_invalid_id_token',
|
||||
status: 401,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.social_invalid_id_token',
|
||||
status: 401,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.authorization_failed',
|
||||
status: 401,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.authorization_failed',
|
||||
status: 401,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.rate_limit_exceeded',
|
||||
status: 429,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.rate_limit_exceeded',
|
||||
status: 429,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
code: 'connector.general',
|
||||
status: 400,
|
||||
},
|
||||
{ message }
|
||||
)
|
||||
new RequestError({
|
||||
code: 'connector.general',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue