From c6f2546126ec48da0ef28f939a062c844c03b2b7 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Fri, 29 Apr 2022 10:37:13 +0800 Subject: [PATCH] feat(core): fix connectors' initialization --- packages/core/package.json | 9 + packages/core/src/connectors/alipay/README.md | 2 - .../src/connectors/alipay/config-template.md | 5 - .../core/src/connectors/alipay/constant.ts | 10 - .../core/src/connectors/alipay/index.test.ts | 318 ------------------ packages/core/src/connectors/alipay/index.ts | 206 ------------ packages/core/src/connectors/alipay/mock.ts | 23 -- packages/core/src/connectors/alipay/types.ts | 45 --- .../core/src/connectors/aliyun-dm/README.md | 2 - .../connectors/aliyun-dm/config-template.md | 23 -- .../src/connectors/aliyun-dm/index.test.ts | 49 --- .../core/src/connectors/aliyun-dm/index.ts | 106 ------ .../aliyun-dm/single-send-mail.test.ts | 26 -- .../connectors/aliyun-dm/single-send-mail.ts | 42 --- .../core/src/connectors/aliyun-sms/README.md | 2 - .../connectors/aliyun-sms/config-template.md | 31 -- .../src/connectors/aliyun-sms/index.test.ts | 61 ---- .../core/src/connectors/aliyun-sms/index.ts | 120 ------- .../aliyun-sms/single-send-text.test.ts | 30 -- .../connectors/aliyun-sms/single-send-text.ts | 36 -- .../core/src/connectors/facebook/README.md | 2 - .../connectors/facebook/config-template.md | 4 - .../core/src/connectors/facebook/constant.ts | 13 - .../src/connectors/facebook/index.test.ts | 120 ------- .../core/src/connectors/facebook/index.ts | 148 -------- packages/core/src/connectors/github/README.md | 28 -- .../src/connectors/github/config-template.md | 4 - .../core/src/connectors/github/constant.ts | 4 - .../core/src/connectors/github/index.test.ts | 87 ----- packages/core/src/connectors/github/index.ts | 140 -------- packages/core/src/connectors/google/README.md | 2 - .../src/connectors/google/config-template.md | 4 - .../core/src/connectors/google/constant.ts | 4 - .../core/src/connectors/google/index.test.ts | 96 ------ packages/core/src/connectors/google/index.ts | 155 --------- packages/core/src/connectors/index.ts | 35 +- packages/core/src/connectors/types.ts | 78 +---- .../src/connectors/utilities/aliyun.test.ts | 59 ---- .../core/src/connectors/utilities/aliyun.ts | 84 ----- .../core/src/connectors/utilities/index.ts | 9 - .../src/connectors/wechat-native/constant.ts | 2 - .../connectors/wechat-native/index.test.ts | 24 -- .../src/connectors/wechat-native/index.ts | 30 -- packages/core/src/connectors/wechat/README.md | 2 - .../src/connectors/wechat/config-template.md | 4 - .../core/src/connectors/wechat/constant.ts | 4 - .../core/src/connectors/wechat/index.test.ts | 134 -------- packages/core/src/connectors/wechat/index.ts | 155 --------- packages/core/src/lib/passcode.test.ts | 3 +- .../middleware/koa-connector-error-handle.ts | 2 +- packages/core/src/routes/connector.test.ts | 12 +- pnpm-lock.yaml | 18 + 52 files changed, 59 insertions(+), 2553 deletions(-) delete mode 100644 packages/core/src/connectors/alipay/README.md delete mode 100644 packages/core/src/connectors/alipay/config-template.md delete mode 100644 packages/core/src/connectors/alipay/constant.ts delete mode 100644 packages/core/src/connectors/alipay/index.test.ts delete mode 100644 packages/core/src/connectors/alipay/index.ts delete mode 100644 packages/core/src/connectors/alipay/mock.ts delete mode 100644 packages/core/src/connectors/alipay/types.ts delete mode 100644 packages/core/src/connectors/aliyun-dm/README.md delete mode 100644 packages/core/src/connectors/aliyun-dm/config-template.md delete mode 100644 packages/core/src/connectors/aliyun-dm/index.test.ts delete mode 100644 packages/core/src/connectors/aliyun-dm/index.ts delete mode 100644 packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts delete mode 100644 packages/core/src/connectors/aliyun-dm/single-send-mail.ts delete mode 100644 packages/core/src/connectors/aliyun-sms/README.md delete mode 100644 packages/core/src/connectors/aliyun-sms/config-template.md delete mode 100644 packages/core/src/connectors/aliyun-sms/index.test.ts delete mode 100644 packages/core/src/connectors/aliyun-sms/index.ts delete mode 100644 packages/core/src/connectors/aliyun-sms/single-send-text.test.ts delete mode 100644 packages/core/src/connectors/aliyun-sms/single-send-text.ts delete mode 100644 packages/core/src/connectors/facebook/README.md delete mode 100644 packages/core/src/connectors/facebook/config-template.md delete mode 100644 packages/core/src/connectors/facebook/constant.ts delete mode 100644 packages/core/src/connectors/facebook/index.test.ts delete mode 100644 packages/core/src/connectors/facebook/index.ts delete mode 100644 packages/core/src/connectors/github/README.md delete mode 100644 packages/core/src/connectors/github/config-template.md delete mode 100644 packages/core/src/connectors/github/constant.ts delete mode 100644 packages/core/src/connectors/github/index.test.ts delete mode 100644 packages/core/src/connectors/github/index.ts delete mode 100644 packages/core/src/connectors/google/README.md delete mode 100644 packages/core/src/connectors/google/config-template.md delete mode 100644 packages/core/src/connectors/google/constant.ts delete mode 100644 packages/core/src/connectors/google/index.test.ts delete mode 100644 packages/core/src/connectors/google/index.ts delete mode 100644 packages/core/src/connectors/utilities/aliyun.test.ts delete mode 100644 packages/core/src/connectors/utilities/aliyun.ts delete mode 100644 packages/core/src/connectors/wechat-native/constant.ts delete mode 100644 packages/core/src/connectors/wechat-native/index.test.ts delete mode 100644 packages/core/src/connectors/wechat-native/index.ts delete mode 100644 packages/core/src/connectors/wechat/README.md delete mode 100644 packages/core/src/connectors/wechat/config-template.md delete mode 100644 packages/core/src/connectors/wechat/constant.ts delete mode 100644 packages/core/src/connectors/wechat/index.test.ts delete mode 100644 packages/core/src/connectors/wechat/index.ts diff --git a/packages/core/package.json b/packages/core/package.json index ba4224a00..9f55c337d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,15 @@ "test:report": "codecov -F core" }, "dependencies": { + "@logto/connector-alipay": "^0.1.0", + "@logto/connector-aliyun-dm": "^0.1.0", + "@logto/connector-aliyun-sms": "^0.1.0", + "@logto/connector-facebook": "^0.1.0", + "@logto/connector-github": "^0.1.0", + "@logto/connector-google": "^0.1.0", + "@logto/connector-types": "^0.1.0", + "@logto/connector-wechat": "^0.1.0", + "@logto/connector-wechat-native": "^0.1.0", "@logto/phrases": "^0.1.0", "@logto/schemas": "^0.1.0", "@silverhand/essentials": "^1.1.0", diff --git a/packages/core/src/connectors/alipay/README.md b/packages/core/src/connectors/alipay/README.md deleted file mode 100644 index 03eaf5850..000000000 --- a/packages/core/src/connectors/alipay/README.md +++ /dev/null @@ -1,2 +0,0 @@ -### Alipay Web Social Connector README -placeholder diff --git a/packages/core/src/connectors/alipay/config-template.md b/packages/core/src/connectors/alipay/config-template.md deleted file mode 100644 index 8ccabf50b..000000000 --- a/packages/core/src/connectors/alipay/config-template.md +++ /dev/null @@ -1,5 +0,0 @@ -{ - "appId": "", - "signType": "", - "privateKey": "" -} diff --git a/packages/core/src/connectors/alipay/constant.ts b/packages/core/src/connectors/alipay/constant.ts deleted file mode 100644 index 19a60b49a..000000000 --- a/packages/core/src/connectors/alipay/constant.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const authorizationEndpoint = 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm'; -export const alipayEndpoint = 'https://openapi.alipay.com/gateway.do'; -export const scope = 'auth_user'; -export const methodForAccessToken = 'alipay.system.oauth.token'; -export const methodForUserInfo = 'alipay.user.info.share'; -export const alipaySigningAlgorithmMapping = { - RSA: 'RSA-SHA1', - RSA2: 'RSA-SHA256', -} as const; -export const alipaySigningAlgorithms = ['RSA', 'RSA2'] as const; diff --git a/packages/core/src/connectors/alipay/index.test.ts b/packages/core/src/connectors/alipay/index.test.ts deleted file mode 100644 index 5f6bb5b8f..000000000 --- a/packages/core/src/connectors/alipay/index.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import nock from 'nock'; -import snakeCaseKeys from 'snakecase-keys'; - -import * as AlipayMethods from '.'; -import { ConnectorError, ConnectorErrorCodes } from '../types'; -import { getConnectorConfig, getFormattedDate } from '../utilities'; -import { - alipayEndpoint, - authorizationEndpoint, - methodForAccessToken, - methodForUserInfo, -} from './constant'; -import { - mockedAlipayConfig, - mockedAlipayConfigWithValidPrivateKey, - mockedAlipayPublicParameters, -} from './mock'; - -jest.mock('../utilities'); - -beforeAll(() => { - (getConnectorConfig as jest.MockedFunction).mockResolvedValue( - mockedAlipayConfig - ); - (getFormattedDate as jest.MockedFunction).mockReturnValue( - '2022-02-22 22:22:22' - ); -}); - -const listenJSONParse = jest.spyOn(JSON, 'parse'); -const listenJSONStringify = jest.spyOn(JSON, 'stringify'); -const mockSigningParameters = jest.spyOn(AlipayMethods, 'signingPamameters'); - -describe('validateConfig', () => { - it('should pass on valid config', async () => { - await expect( - AlipayMethods.validateConfig({ appId: 'appId', privateKey: 'privateKey', signType: 'RSA' }) - ).resolves.not.toThrow(); - }); - it('should throw on empty config', async () => { - await expect(AlipayMethods.validateConfig({})).rejects.toThrowError(); - }); - it('should throw when missing required properties', async () => { - await expect(AlipayMethods.validateConfig({ appId: 'appId' })).rejects.toThrowError(); - }); -}); - -describe('signingParameters', () => { - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - const testingParameters = { - ...mockedAlipayPublicParameters, - ...mockedAlipayConfigWithValidPrivateKey, - method: methodForAccessToken, - code: '7ffeb112fbb6495c9e7dfb720380DD39', - }; - - it('should return exact signature with the given parameters (functionality check)', () => { - const decamelizedParameters = AlipayMethods.signingPamameters(testingParameters); - expect(decamelizedParameters.sign).toBe( - 'td9+u0puul3HgbwLGL1X6z/vKKB/K25K5pjtLT/snQOp292RX3Y5j+FQUVuazTI2l65GpoSgA83LWNT9htQgtmdBmkCQ3bO6RWs38+2ZmBmH7MvpHx4ebUDhtebLUmHNuRFaNcpAZW92b0ZSuuJuahpLK8VNBgXljq+x0aD7WCRudPxc9fikR65NGxr5bwepl/9IqgMxwtajh1+PEJyhGGJhJxS1dCktGN0EiWXWNiogYT8NlFVCmw7epByKzCBWu4sPflU52gJMFHTdbav/0Tk/ZBs8RyP8Z8kcJA0jom2iT+dHqDpgkdzEmsR360UVNKCu5X7ltIiiObsAWmfluQ==' - ); - }); - - it('should return exact signature with the given parameters (with empty property in testingParameters)', () => { - const decamelizedParameters = AlipayMethods.signingPamameters({ - ...testingParameters, - emptyProperty: '', - }); - expect(decamelizedParameters.sign).toBe( - 'td9+u0puul3HgbwLGL1X6z/vKKB/K25K5pjtLT/snQOp292RX3Y5j+FQUVuazTI2l65GpoSgA83LWNT9htQgtmdBmkCQ3bO6RWs38+2ZmBmH7MvpHx4ebUDhtebLUmHNuRFaNcpAZW92b0ZSuuJuahpLK8VNBgXljq+x0aD7WCRudPxc9fikR65NGxr5bwepl/9IqgMxwtajh1+PEJyhGGJhJxS1dCktGN0EiWXWNiogYT8NlFVCmw7epByKzCBWu4sPflU52gJMFHTdbav/0Tk/ZBs8RyP8Z8kcJA0jom2iT+dHqDpgkdzEmsR360UVNKCu5X7ltIiiObsAWmfluQ==' - ); - }); - - it('should not call JSON.parse() when biz_content is empty', () => { - AlipayMethods.signingPamameters(testingParameters); - expect(listenJSONParse).not.toHaveBeenCalled(); - }); - - it('should call JSON.parse() when biz_content is not empty', () => { - AlipayMethods.signingPamameters({ - ...testingParameters, - biz_content: JSON.stringify({ AB: 'AB' }), - }); - expect(listenJSONParse).toHaveBeenCalled(); - }); - - it('should call JSON.stringify() when some value is object string', () => { - AlipayMethods.signingPamameters({ - ...testingParameters, - testObject: JSON.stringify({ AB: 'AB' }), - }); - expect(listenJSONStringify).toHaveBeenCalled(); - }); -}); - -describe('getAuthorizationUri', () => { - it('should get a valid uri by redirectUri and state', async () => { - const authorizationUri = await AlipayMethods.getAuthorizationUri( - 'http://localhost:3001/callback', - 'some_state' - ); - expect(authorizationUri).toEqual( - `${authorizationEndpoint}?app_id=2021000000000000&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&scope=auth_user&state=some_state` - ); - }); -}); - -describe('getAccessToken', () => { - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - const alipayEndpointUrl = new URL(alipayEndpoint); - const parameters = { - ...mockedAlipayPublicParameters, - method: methodForAccessToken, - ...mockedAlipayConfig, - code: 'code', - sign: 'sign', - }; - const searchParameters = new URLSearchParams(snakeCaseKeys(parameters)); - - it('should get an accessToken by exchanging with code', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query(searchParameters) - .reply(200, { - alipay_system_oauth_token_response: { - user_id: '2088000000000000', - access_token: 'access_token', - expires_in: '3600', - refresh_token: 'refresh_token', - re_expires_in: '7200', // Expiring time of refresh token, in seconds - }, - sign: '', - }); - - const { accessToken } = await AlipayMethods.getAccessToken('code'); - expect(accessToken).toEqual('access_token'); - }); - - it('should throw when accessToken is empty', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query(searchParameters) - .reply(200, { - alipay_system_oauth_token_response: { - user_id: '2088000000000000', - access_token: undefined, - expires_in: '3600', - refresh_token: 'refresh_token', - re_expires_in: '7200', // Expiring time of refresh token, in seconds - }, - sign: '', - }); - - await expect(AlipayMethods.getAccessToken('code')).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) - ); - }); - - it('should fail with wrong code', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query(new URLSearchParams(snakeCaseKeys({ ...parameters, code: 'wrong_code' }))) - .reply(200, { - error_response: { - code: '20001', - msg: 'Invalid code', - sub_code: 'isv.code-invalid ', - }, - sign: '', - }); - - await expect(AlipayMethods.getAccessToken('wrong_code')).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid code') - ); - }); -}); - -describe('getUserInfo', () => { - afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); - }); - - const alipayEndpointUrl = new URL(alipayEndpoint); - const parameters = { - ...mockedAlipayPublicParameters, - method: methodForUserInfo, - ...mockedAlipayConfig, - auth_token: 'access_token', - biz_content: '{}', - sign: 'sign', - }; - const searchParameters = new URLSearchParams(snakeCaseKeys(parameters)); - - it('should get userInfo with accessToken', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query(searchParameters) - .reply(200, { - alipay_user_info_share_response: { - code: '10000', - msg: 'Success', - user_id: '2088000000000000', - nick_name: 'PlayboyEric', - avatar: 'https://www.alipay.com/xxx.jpg', - }, - sign: '', - }); - - const { id, name, avatar } = await AlipayMethods.getUserInfo({ accessToken: 'access_token' }); - expect(id).toEqual('2088000000000000'); - expect(name).toEqual('PlayboyEric'); - expect(avatar).toEqual('https://www.alipay.com/xxx.jpg'); - }); - - it('should throw with wrong accessToken', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query( - new URLSearchParams(snakeCaseKeys({ ...parameters, auth_token: 'wrong_access_token' })) - ) - .reply(200, { - alipay_user_info_share_response: { - code: '20001', - msg: 'Invalid auth token', - sub_code: 'aop.invalid-auth-token', - }, - sign: '', - }); - - await expect( - AlipayMethods.getUserInfo({ accessToken: 'wrong_access_token' }) - ).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token') - ); - }); - - it('should throw General error with other response error codes', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query( - new URLSearchParams(snakeCaseKeys({ ...parameters, auth_token: 'wrong_access_token' })) - ) - .reply(200, { - alipay_user_info_share_response: { - code: '40002', - msg: 'Invalid parameter', - sub_code: 'isv.invalid-parameter', - }, - sign: '', - }); - - await expect( - AlipayMethods.getUserInfo({ accessToken: 'wrong_access_token' }) - ).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.General)); - }); - - it('should throw with right accessToken but empty userInfo', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query(searchParameters) - .reply(200, { - alipay_user_info_share_response: { - code: '10000', - msg: 'Success', - user_id: undefined, - nick_name: 'PlayboyEric', - avatar: 'https://www.alipay.com/xxx.jpg', - }, - sign: '', - }); - - await expect(AlipayMethods.getUserInfo({ accessToken: 'access_token' })).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.InvalidResponse) - ); - }); - - it('should throw with other request errors', async () => { - mockSigningParameters.mockImplementationOnce((parameters) => { - return snakeCaseKeys({ ...parameters, sign: 'sign' }); - }); - nock(alipayEndpointUrl.origin) - .post(alipayEndpointUrl.pathname) - .query(searchParameters) - .reply(500); - - await expect(AlipayMethods.getUserInfo({ accessToken: 'access_token' })).rejects.toThrow(); - }); -}); diff --git a/packages/core/src/connectors/alipay/index.ts b/packages/core/src/connectors/alipay/index.ts deleted file mode 100644 index 5d460574d..000000000 --- a/packages/core/src/connectors/alipay/index.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * The Implementation of OpenID Connect of Alipay Web Open Platform. - * https://opendocs.alipay.com/support/01rg6h - * https://opendocs.alipay.com/open/263/105808 - * https://opendocs.alipay.com/open/01emu5 - */ -import * as crypto from 'crypto'; -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import got from 'got'; -import * as iconv from 'iconv-lite'; -import { stringify } from 'query-string'; -import snakeCaseKeys from 'snakecase-keys'; - -import assertThat from '@/utils/assert-that'; - -import { - ConnectorError, - ConnectorErrorCodes, - ConnectorMetadata, - ConnectorType, - GetAccessToken, - GetAuthorizationUri, - GetUserInfo, - ValidateConfig, -} from '../types'; -import { getConnectorConfig, getConnectorRequestTimeout, getFormattedDate } from '../utilities'; -import { - alipayEndpoint, - authorizationEndpoint, - methodForAccessToken, - methodForUserInfo, - scope, - alipaySigningAlgorithmMapping, -} from './constant'; -import { alipayConfigGuard, AlipayConfig, AccessTokenResponse, UserInfoResponse } from './types'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'alipay', - type: ConnectorType.Social, - name: { - en: 'Sign In with Alipay', - 'zh-CN': '支付宝登录', - }, - // TODO: add the real logo URL (LOG-1823) - logo: './logo.png', - description: { - en: 'Sign In with Alipay', - 'zh-CN': '支付宝登录', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = alipayConfigGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -// Reference: https://github.com/alipay/alipay-sdk-nodejs-all/blob/10d78e0adc7f310d5b07567ce7e4c13a3f6c768f/lib/util.ts -export const signingPamameters = ( - parameters: AlipayConfig & Record -): Record => { - const { biz_content, privateKey, ...rest } = parameters; - const signParameters = snakeCaseKeys( - biz_content - ? { - ...rest, - bizContent: JSON.stringify(snakeCaseKeys(JSON.parse(biz_content))), - } - : rest - ); - - const decamelizeParameters = snakeCaseKeys(signParameters); - - // eslint-disable-next-line @silverhand/fp/no-mutating-methods - const sortedParametersAsString = Object.entries(decamelizeParameters) - .map(([key, value]) => { - // Supported Encodings can be found at https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings - - if (value) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - return `${key}=${iconv.encode(value, rest.charset ?? 'UTF8')}`; - } - - return ''; - }) - .filter((keyValueString) => keyValueString) - .sort() - .join('&'); - - const sign = crypto - .createSign(alipaySigningAlgorithmMapping[rest.signType]) - .update(sortedParametersAsString, 'utf8') - .sign(privateKey, 'base64'); - - return { ...decamelizeParameters, sign }; -}; - -export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { - const { appId: app_id } = await getConnectorConfig(metadata.id); - - const redirect_uri = encodeURI(redirectUri); - - return `${authorizationEndpoint}?${stringify({ - app_id, - redirect_uri, // The variable `redirectUri` should match {appId, appSecret} - scope, - state, - })}`; -}; - -export const getAccessToken: GetAccessToken = async (authCode) => { - const config = await getConnectorConfig(metadata.id); - const initSearchParameters = { - method: methodForAccessToken, - format: 'JSON', - timestamp: getFormattedDate(), - version: '1.0', - grant_type: 'authorization_code', - code: authCode, - charset: 'UTF8', - ...config, - }; - const signedSearchParameters = signingPamameters(initSearchParameters); - - const response = await got - .post(alipayEndpoint, { - searchParams: signedSearchParameters, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - const { msg, sub_msg } = response.error_response ?? {}; - assertThat( - response.alipay_system_oauth_token_response, - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg) - ); - const { access_token: accessToken } = response.alipay_system_oauth_token_response; - - assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); - - return { accessToken }; -}; - -export const getUserInfo: GetUserInfo = async (accessTokenObject) => { - const { accessToken } = accessTokenObject; - - const config = await getConnectorConfig(metadata.id); - const initSearchParameters = { - method: methodForUserInfo, - format: 'JSON', - timestamp: getFormattedDate(), - version: '1.0', - grant_type: 'authorization_code', - auth_token: accessToken, - biz_content: JSON.stringify({}), - charset: 'UTF8', - ...config, - }; - const signedSearchParameters = signingPamameters(initSearchParameters); - - const response = await got - .post(alipayEndpoint, { - searchParams: signedSearchParameters, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - const { - user_id: id, - avatar, - nick_name: name, - sub_msg, - sub_code, - msg, - code, - } = response.alipay_user_info_share_response; - - if (sub_msg || sub_code) { - if (code === '20001') { - throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg); - } - throw new ConnectorError(ConnectorErrorCodes.General); - } - // TODO: elaborate on the error messages for all social connectors (see LOG-2157) - - assertThat(id, new ConnectorError(ConnectorErrorCodes.InvalidResponse)); - - return { id, avatar, name }; -}; diff --git a/packages/core/src/connectors/alipay/mock.ts b/packages/core/src/connectors/alipay/mock.ts deleted file mode 100644 index baf71b011..000000000 --- a/packages/core/src/connectors/alipay/mock.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AlipayConfig } from './types'; - -export const mockedAlipayConfig: AlipayConfig = { - appId: '2021000000000000', - signType: 'RSA2', - privateKey: '', -}; - -export const mockedAlipayConfigWithValidPrivateKey: AlipayConfig = { - appId: '2021000000000000', - signType: 'RSA2', - privateKey: - '-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC52SvnlRfzJJDR\nA1h4MX2JWV7Yt1j+1gvtQuLh0RYbE0AgRyz8CXFcJegO8gNyUQ05vrc1RMVzvNh8\njfjLpIX8an88KE4FyoG5P8NWrwPw5ZXOnzdvNxAV8QWOU+rT4WAdCsx4++mLlb5v\nGL18R77f3WLgY23bFtcGr9q7/qOaLzNxEe4idX1eLf7Ba/gQRY0awA55/Epd1Mi7\nLqTfxTd11PoBZQPe0vnuChp3P2l1MNpIJ5G1eQ4RXgI4UMClEbGRlBN7GUlXy5p7\ng6RtvOcwmBNoE4i0/HbvaanY3u7oenST3iSzEXa2hXMjnZPvg0G4Y5mq/V6XJPTh\nJrFc9XzFAgMBAAECggEAXfmNtN10LdN4kugBLU3BL9mMF0Om8b1kbIXc2djzN5+l\nVm0HNy7DLphQXnZL/ds0N9XTKFFtEpgUU+8qNjcsNTXYvp+WzGDY9cZjTQrUkFRX\nSxLBYjBSpvWoHI8ceCVHh4f1Wtvu/VEr6Vt2PUi+IM7+d35vh1BmTJBRp6wcKBMH\nXdfjWIi5z37pTXD3OTfUjBCtzA2DX0vY6UTsmD9UI0Mb6IJdT6qugiGODFdlsduA\nWJoZlXV1VbHcvGt7DoeQgzA45sr5siUnm+ntTVBHOR/hoZQrr0DY/O/MLKYUj/+r\nZMKKpx/7VHnWfMia2EOHfjW8vUlnraUzI+5E2/FzIQKBgQDgi7S7pfRux8YONGP2\nRtHPkF8d0YllsfKedhqF3cQlJ1dhxzVqHOi1IFn6ttuuYy5UsP5apYa2kj2UUPCa\nZGGi19Vnc+RHThpR4K6/OGFrpbINAgiVJLj7F8GXzqeA7W2ZHMp1R+oB+oTxih6t\nU0dbeTP01kbBV1/7+ZUKPhLE6QKBgQDT4cMgq01F/WIGGd1GUHZQjH5bqtNiJpIf\n2Q2OTw/gn1DVnwDXpHuXPxtC3NRoaRW/dTqsF6AAkMja3voPM3sYJurGBdU8pZPC\nquc9mqqu6TR5gX3KL1lSESvMBEgfLUy/f0gI3JNw1mG17pIhnXmOB2be3HfZPcj3\nwKWlluY/fQKBgDLll97c3A3sPGll2K6vGMmqmNTCdRlW/36JmLN1NAuT4kuoguP9\nj4XWwm6A2kSp+It73vue/20MsuaWfiMQ08y8jYO4kirTekXK3vE7D2H+GeC28EkW\nHNPVa61ES1V++9Oz4fQ5i8JNDatOOmvhL5B9ZZh+pWUXsAsGZJEAxvJZAoGAMPHO\n5GYN1KQil6wz3EFMA3Fg4wYEDIFCcg7uvbfvwACtaJtxU18QmbCfOIPQoUndFzwa\nUJSohljrvPuTIh3PSpX618GTL45EIszd2/I1iXAfig3qo+DqLjX/OwKmMmWBfB8H\n4dwqRv+O1LsGkLNS2AdHsSWWnd1S5kBfQ3AnQfUCgYACM8ldXZv7uGt9uZBmxile\nB0Hg5w7F1v9VD/m9ko+INAISz8OVkD83pCEoyHwlr20JjiF+yzAakOuq6rBi+l/V\n1veSiTDUcZhciuq1G178dFYepJqisFBu7bAM+WBS4agTTtxdSLZkHeS4VX+H3DOc\ntri43NXw6QS7uQ5/+2TsEw==\n-----END PRIVATE KEY-----', -}; - -export const mockedAlipayPublicParameters = { - format: 'JSON', - grantType: 'authorization_code', - timestamp: '2022-02-22 22:22:22', - version: '1.0', - charset: 'UTF8', - method: '', -}; diff --git a/packages/core/src/connectors/alipay/types.ts b/packages/core/src/connectors/alipay/types.ts deleted file mode 100644 index 8cc550153..000000000 --- a/packages/core/src/connectors/alipay/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from 'zod'; - -import { alipaySigningAlgorithms } from './constant'; - -export const alipayConfigGuard = z.object({ - appId: z.string(), - privateKey: z.string(), - signType: z.enum(alipaySigningAlgorithms), -}); - -export type AlipayConfig = z.infer; - -// `error_response` and `alipay_system_oauth_token_response` are mutually exclusive. -export type AccessTokenResponse = { - error_response?: { - code: string; - msg: string; // To know `code` and `msg` details, see: https://opendocs.alipay.com/common/02km9f - sub_code?: string; - sub_msg?: string; - }; - sign: string; // To know `sign` details, see: https://opendocs.alipay.com/common/02kf5q - alipay_system_oauth_token_response?: { - user_id: string; // Unique Alipay ID, 16 digits starts with '2088' - access_token: string; - expires_in: string; // In seconds - refresh_token: string; - re_expires_in: string; // Expiring time of refresh token, in seconds - }; -}; - -export type UserInfoResponse = { - sign: string; // To know `sign` details, see: https://opendocs.alipay.com/common/02kf5q - alipay_user_info_share_response: { - user_id?: string; // String of digits with max length of 16 - avatar?: string; // URL of avatar - province?: string; - city?: string; - nick_name?: string; - gender?: string; // Enum type: 'F' for female, 'M' for male - code: string; - msg: string; // To know `code` and `msg` details, see: https://opendocs.alipay.com/common/02km9f - sub_code?: string; - sub_msg?: string; - }; -}; diff --git a/packages/core/src/connectors/aliyun-dm/README.md b/packages/core/src/connectors/aliyun-dm/README.md deleted file mode 100644 index 339f0b4de..000000000 --- a/packages/core/src/connectors/aliyun-dm/README.md +++ /dev/null @@ -1,2 +0,0 @@ -### Aliyun DM README -placeholder diff --git a/packages/core/src/connectors/aliyun-dm/config-template.md b/packages/core/src/connectors/aliyun-dm/config-template.md deleted file mode 100644 index 33868f87b..000000000 --- a/packages/core/src/connectors/aliyun-dm/config-template.md +++ /dev/null @@ -1,23 +0,0 @@ -{ - "accessKeyId": "", - "accessKeySecret": "", - "accountName": "", - "fromAlias": "", - "templates": [ - { - "usageType": "SIGN_IN", - "subject": "", - "content": "" - }, - { - "usageType": "REGISTER", - "subject": "", - "content": "" - }, - { - "usageType": "TEST", - "subject": "", - "content": "" - } - ] -} diff --git a/packages/core/src/connectors/aliyun-dm/index.test.ts b/packages/core/src/connectors/aliyun-dm/index.test.ts deleted file mode 100644 index 7552f485c..000000000 --- a/packages/core/src/connectors/aliyun-dm/index.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { sendMessage, validateConfig } from '.'; -import { singleSendMail } from './single-send-mail'; - -jest.mock('./single-send-mail'); -jest.mock('../utilities', () => ({ - getConnectorConfig: async () => ({ - accessKeyId: 'accessKeyId', - accessKeySecret: 'accessKeySecret', - accountName: 'accountName', - templates: [ - { - usageType: 'SignIn', - content: 'Your code is {{code}}, {{code}} is your code', - subject: 'subject', - }, - ], - }), -})); - -describe('validateConfig()', () => { - it('should pass on valid config', async () => { - await expect( - validateConfig({ - accessKeyId: 'accessKeyId', - accessKeySecret: 'accessKeySecret', - accountName: 'accountName', - templates: [], - }) - ).resolves.not.toThrow(); - }); - it('throws if config is invalid', async () => { - await expect(validateConfig({})).rejects.toThrow(); - }); -}); - -describe('sendMessage()', () => { - it('should call singleSendMail() and replace code in content', async () => { - await sendMessage('to@email.com', 'SignIn', { code: '1234' }); - expect(singleSendMail).toHaveBeenCalledWith( - expect.objectContaining({ - HtmlBody: 'Your code is 1234, 1234 is your code', - }), - expect.anything() - ); - }); - it('throws if template is missing', async () => { - await expect(sendMessage('to@email.com', 'Register', { code: '1234' })).rejects.toThrow(); - }); -}); diff --git a/packages/core/src/connectors/aliyun-dm/index.ts b/packages/core/src/connectors/aliyun-dm/index.ts deleted file mode 100644 index 3fbdcecd9..000000000 --- a/packages/core/src/connectors/aliyun-dm/index.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import { z } from 'zod'; - -import assertThat from '@/utils/assert-that'; - -import { - ConnectorError, - ConnectorErrorCodes, - ConnectorMetadata, - ConnectorType, - EmailSendMessageFunction, - ValidateConfig, -} from '../types'; -import { getConnectorConfig } from '../utilities'; -import { singleSendMail } from './single-send-mail'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'aliyun-dm', - type: ConnectorType.Email, - name: { - en: 'Aliyun Direct Mail', - 'zh-CN': '阿里云邮件推送', - }, - // TODO: add the real logo URL (LOG-1823) - logo: './logo.png', - description: { - en: 'A simple and efficient email service to help you send transactional notifications and batch email.', - 'zh-CN': - '邮件推送(DirectMail)是款简单高效的电子邮件群发服务,构建在阿里云基础之上,帮您快速、精准地实现事务邮件、通知邮件和批量邮件的发送。', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -/** - * UsageType here is used to specify the use case of the template, can be either - * 'Register', 'SignIn', 'ForgotPassword' or 'Test'. - */ -const templateGuard = z.object({ - usageType: z.string(), - subject: z.string(), - content: z.string(), // With variable {{code}}, support HTML -}); - -const configGuard = z.object({ - accessKeyId: z.string(), - accessKeySecret: z.string(), - accountName: z.string(), - fromAlias: z.string().optional(), - templates: z.array(templateGuard), -}); - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = configGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -export type AliyunDmConfig = z.infer; - -export const sendMessage: EmailSendMessageFunction = async (address, type, data) => { - const config = await getConnectorConfig(metadata.id); - await validateConfig(config); - const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config; - const template = templates.find((template) => template.usageType === type); - - assertThat( - template, - new ConnectorError( - ConnectorErrorCodes.TemplateNotFound, - `Cannot find template for type: ${type}` - ) - ); - - return singleSendMail( - { - AccessKeyId: accessKeyId, - AccountName: accountName, - ReplyToAddress: 'false', - AddressType: '1', - ToAddress: address, - FromAlias: fromAlias, - Subject: template.subject, - HtmlBody: - typeof data.code === 'string' - ? template.content.replace(/{{code}}/g, data.code) - : template.content, - }, - accessKeySecret - ); -}; diff --git a/packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts b/packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts deleted file mode 100644 index 2568ad4ab..000000000 --- a/packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { request } from '../utilities/aliyun'; -import { singleSendMail } from './single-send-mail'; - -jest.mock('../utilities/aliyun'); - -describe('singleSendMail', () => { - it('should call request with action SingleSendMail', async () => { - await singleSendMail( - { - AccessKeyId: '', - AccountName: 'noreply@example.com', - AddressType: '1', - FromAlias: 'CompanyName', - HtmlBody: 'test from logto', - ReplyToAddress: 'false', - Subject: 'test', - ToAddress: 'user@example.com', - }, - '' - ); - const calledData = (request as jest.MockedFunction).mock.calls[0]; - expect(calledData).not.toBeUndefined(); - const payload = calledData?.[1]; - expect(payload).toHaveProperty('Action', 'SingleSendMail'); - }); -}); diff --git a/packages/core/src/connectors/aliyun-dm/single-send-mail.ts b/packages/core/src/connectors/aliyun-dm/single-send-mail.ts deleted file mode 100644 index 6198969af..000000000 --- a/packages/core/src/connectors/aliyun-dm/single-send-mail.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { PublicParameters, request } from '../utilities/aliyun'; - -/** - * @doc https://help.aliyun.com/document_detail/29444.html - * - */ -interface SingleSendMail { - AccountName: string; - AddressType: '0' | '1'; - ClickTrace?: '0' | '1'; - FromAlias?: string; - HtmlBody?: string; - ReplyToAddress: 'true' | 'false'; - Subject: string; - TagName?: string; - TextBody?: string; - ToAddress: string; -} - -const Endpoint = 'https://dm.aliyuncs.com/'; - -const staticConfigs = { - Format: 'json', - SignatureMethod: 'HMAC-SHA1', - SignatureVersion: '1.0', - Version: '2015-11-23', -}; - -/** - * @doc https://help.aliyun.com/document_detail/29444.html - * - */ -export const singleSendMail = async ( - parameters: PublicParameters & SingleSendMail, - accessKeySecret: string -) => { - return request<{ EnvId: string; RequestId: string }>( - Endpoint, - { Action: 'SingleSendMail', ...staticConfigs, ...parameters }, - accessKeySecret - ); -}; diff --git a/packages/core/src/connectors/aliyun-sms/README.md b/packages/core/src/connectors/aliyun-sms/README.md deleted file mode 100644 index c67a67b8a..000000000 --- a/packages/core/src/connectors/aliyun-sms/README.md +++ /dev/null @@ -1,2 +0,0 @@ -### Aliyun SMS README -placeholder diff --git a/packages/core/src/connectors/aliyun-sms/config-template.md b/packages/core/src/connectors/aliyun-sms/config-template.md deleted file mode 100644 index 99e00354e..000000000 --- a/packages/core/src/connectors/aliyun-sms/config-template.md +++ /dev/null @@ -1,31 +0,0 @@ -{ - "accessKeyId": "", - "accessKeySecret": "", - "signName": "", - "templates": [ - { - "type": 0, - "usageType": "SIGN_IN", - "code": "", - "name": "", - "content": "", - "remark": "" - }, - { - "type": 0, - "usageType": "REGISTER", - "code": "", - "name": "", - "content": "", - "remark": "" - }, - { - "type": 0, - "usageType": "TEST", - "code": "", - "name": "", - "content": "", - "remark": "" - }, - ] -} diff --git a/packages/core/src/connectors/aliyun-sms/index.test.ts b/packages/core/src/connectors/aliyun-sms/index.test.ts deleted file mode 100644 index a3552223d..000000000 --- a/packages/core/src/connectors/aliyun-sms/index.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { sendMessage, validateConfig } from '.'; -import { sendSms } from './single-send-text'; - -const defaultConnectorConfig = { - accessKeyId: 'accessKeyId', - accessKeySecret: 'accessKeySecret', - signName: 'signName', - templates: [ - { - usageType: 'SignIn', - code: 'code', - name: 'name', - content: 'content', - remark: 'remark', - }, - ], -}; - -const validConnectorConfig = { - accessKeyId: 'accessKeyId', - accessKeySecret: 'accessKeySecret', - signName: 'signName', - templates: [], -}; - -const phoneTest = '13012345678'; -const codeTest = '1234'; - -jest.mock('./single-send-text'); -jest.mock('../utilities', () => ({ - getConnectorConfig: async () => defaultConnectorConfig, -})); - -describe('validateConfig()', () => { - it('should pass on valid config', async () => { - await expect(validateConfig(validConnectorConfig)).resolves.not.toThrow(); - }); - it('throws if config is invalid', async () => { - await expect(validateConfig({})).rejects.toThrow(); - }); -}); - -describe('sendMessage()', () => { - it('should call singleSendMail() and replace code in content', async () => { - await sendMessage(phoneTest, 'SignIn', { code: codeTest }); - const { templates, ...credentials } = defaultConnectorConfig; - expect(sendSms).toHaveBeenCalledWith( - expect.objectContaining({ - AccessKeyId: credentials.accessKeyId, - PhoneNumbers: phoneTest, - SignName: credentials.signName, - TemplateCode: templates.find(({ usageType }) => usageType === 'SignIn')?.code, - TemplateParam: `{"code":"${codeTest}"}`, - }), - 'accessKeySecret' - ); - }); - it('throws if template is missing', async () => { - await expect(sendMessage(phoneTest, 'Register', { code: codeTest })).rejects.toThrow(); - }); -}); diff --git a/packages/core/src/connectors/aliyun-sms/index.ts b/packages/core/src/connectors/aliyun-sms/index.ts deleted file mode 100644 index 95b2a16bf..000000000 --- a/packages/core/src/connectors/aliyun-sms/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import { z } from 'zod'; - -import assertThat from '@/utils/assert-that'; - -import { - ConnectorError, - ConnectorErrorCodes, - ConnectorMetadata, - ConnectorType, - SmsSendMessageFunction, - ValidateConfig, -} from '../types'; -import { getConnectorConfig } from '../utilities'; -import { sendSms } from './single-send-text'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'aliyun-sms', - type: ConnectorType.SMS, - name: { - en: 'Aliyun Short Message Service', - 'zh-CN': '阿里云短信服务', - }, - // TODO: add the real logo URL (LOG-1823) - logo: './logo.png', - description: { - en: 'Short Message Service (SMS) has a batch sending feature and various API operations to send one-time password (OTP) messages, notification messages, and promotional messages to customers.', - 'zh-CN': - '短信服务(Short Message Service)是指通过调用短信发送API,将指定短信内容发送给指定手机用户。', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -/** - * Details of SmsTemplateType can be found at: - * https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/QuerySmsTemplateList. - * - * For our use case is to send passcode sms for passwordless sign-in/up as well as - * reset password, the default value of type code is set to be 2. - * - */ -enum SmsTemplateType { - Notification = 0, - Promotion = 1, - Passcode = 2, - InternationalMessage = 6, - PureNumber = 7, -} - -/** - * UsageType here is used to specify the use case of the template, can be either - * 'Register', 'SignIn', 'ForgotPassword' or 'Test'. - * - * Type here in the template is used to specify the purpose of sending the sms, - * can be either item in SmsTemplateType. - * As the SMS is applied for sending passcode, the value should always be 2 in our case. - * - */ -const templateGuard = z.object({ - type: z.nativeEnum(SmsTemplateType).default(2), - usageType: z.string(), - code: z.string(), - name: z.string().min(1).max(30), - content: z.string().min(1).max(500), - remark: z.string(), -}); - -const configGuard = z.object({ - accessKeyId: z.string(), - accessKeySecret: z.string(), - signName: z.string(), - templates: z.array(templateGuard), -}); - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = configGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -export type AliyunSmsConfig = z.infer; - -export const sendMessage: SmsSendMessageFunction = async (phone, type, { code }) => { - const config = await getConnectorConfig(metadata.id); - await validateConfig(config); - const { accessKeyId, accessKeySecret, signName, templates } = config; - const template = templates.find(({ usageType }) => usageType === type); - - assertThat( - template, - new ConnectorError(ConnectorErrorCodes.TemplateNotFound, `Cannot find template!`) - ); - - return sendSms( - { - AccessKeyId: accessKeyId, - PhoneNumbers: phone, - SignName: signName, - TemplateCode: template.code, - TemplateParam: JSON.stringify({ code }), - }, - accessKeySecret - ); -}; diff --git a/packages/core/src/connectors/aliyun-sms/single-send-text.test.ts b/packages/core/src/connectors/aliyun-sms/single-send-text.test.ts deleted file mode 100644 index 3ed2750b5..000000000 --- a/packages/core/src/connectors/aliyun-sms/single-send-text.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { customAlphabet } from 'nanoid'; - -import { request } from '../utilities/aliyun'; -import { sendSms } from './single-send-text'; - -export const passcodeLength = 4; -const randomCode = customAlphabet('1234567890', passcodeLength); - -jest.mock('../utilities/aliyun'); - -describe('sendSms', () => { - it('should call request with action sendSms', async () => { - const code = randomCode(); - - await sendSms( - { - AccessKeyId: '', - PhoneNumbers: '13912345678', - SignName: '阿里云短信测试', - TemplateCode: ' SMS_154950909', - TemplateParam: JSON.stringify({ code }), - }, - '' - ); - const calledData = (request as jest.MockedFunction).mock.calls[0]; - expect(calledData).not.toBeUndefined(); - const payload = calledData?.[1]; - expect(payload).toHaveProperty('Action', 'SendSms'); - }); -}); diff --git a/packages/core/src/connectors/aliyun-sms/single-send-text.ts b/packages/core/src/connectors/aliyun-sms/single-send-text.ts deleted file mode 100644 index 5857f00b4..000000000 --- a/packages/core/src/connectors/aliyun-sms/single-send-text.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { PublicParameters, request } from '../utilities/aliyun'; - -/** - * @doc https://help.aliyun.com/document_detail/101414.html - * - */ -interface SendSms { - OutId?: string; - PhoneNumbers: string; // 11 digits w/o prefix (can be multiple phone numbers with separator `,`) - SignName: string; // Name of SMS signature - SmsUpExtendCode?: string; - TemplateCode: string; // Text message template ID - TemplateParam?: string; // Stringified JSON (used to fill in text template) -} - -const Endpoint = 'https://dysmsapi.aliyuncs.com/'; - -const staticConfigs = { - Format: 'json', - RegionId: 'cn-hangzhou', - SignatureMethod: 'HMAC-SHA1', - SignatureVersion: '1.0', - Version: '2017-05-25', -}; - -/** - * @doc https://help.aliyun.com/document_detail/101414.html - * - */ -export const sendSms = async (parameters: PublicParameters & SendSms, accessKeySecret: string) => { - return request<{ BizId: string; Code: string; Message: string; RequestId: string }>( - Endpoint, - { Action: 'SendSms', ...staticConfigs, ...parameters }, - accessKeySecret - ); -}; diff --git a/packages/core/src/connectors/facebook/README.md b/packages/core/src/connectors/facebook/README.md deleted file mode 100644 index 1cd436100..000000000 --- a/packages/core/src/connectors/facebook/README.md +++ /dev/null @@ -1,2 +0,0 @@ -### FB Social Connector README -placeholder diff --git a/packages/core/src/connectors/facebook/config-template.md b/packages/core/src/connectors/facebook/config-template.md deleted file mode 100644 index 081079e46..000000000 --- a/packages/core/src/connectors/facebook/config-template.md +++ /dev/null @@ -1,4 +0,0 @@ -{ - "clientId": "", - "clientSecret": "" -} diff --git a/packages/core/src/connectors/facebook/constant.ts b/packages/core/src/connectors/facebook/constant.ts deleted file mode 100644 index df851696c..000000000 --- a/packages/core/src/connectors/facebook/constant.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Note: If you do not include a version number we will default to the oldest available version, so it's recommended to include the version number in your requests. - * https://developers.facebook.com/docs/graph-api/overview#versions - */ -export const authorizationEndpoint = 'https://www.facebook.com/v13.0/dialog/oauth'; -export const accessTokenEndpoint = 'https://graph.facebook.com/v13.0/oauth/access_token'; -/** - * Note: The /me node is a special endpoint that translates to the object ID of the person or Page whose access token is currently being used to make the API calls. - * https://developers.facebook.com/docs/graph-api/overview#me - * https://developers.facebook.com/docs/graph-api/reference/user#Reading - */ -export const userInfoEndpoint = 'https://graph.facebook.com/v13.0/me'; -export const scope = 'email,public_profile'; diff --git a/packages/core/src/connectors/facebook/index.test.ts b/packages/core/src/connectors/facebook/index.test.ts deleted file mode 100644 index cc4117b2b..000000000 --- a/packages/core/src/connectors/facebook/index.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import nock from 'nock'; - -import { validateConfig, getAuthorizationUri, getAccessToken, getUserInfo } from '.'; -import { ConnectorError, ConnectorErrorCodes } from '../types'; -import { getConnectorConfig } from '../utilities'; -import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant'; - -const clientId = 'client_id_value'; -const clientSecret = 'client_secret_value'; -const code = 'code'; -const dummyRedirectUri = 'dummyRedirectUri'; -const fields = 'id,name,email,picture'; - -jest.mock('../utilities'); - -beforeAll(() => { - (getConnectorConfig as jest.MockedFunction).mockResolvedValue({ - clientId, - clientSecret, - }); -}); - -describe('facebook connector', () => { - describe('validateConfig', () => { - it('should pass on valid config', async () => { - await expect(validateConfig({ clientId, clientSecret })).resolves.not.toThrow(); - }); - - it('should throw on invalid config', async () => { - await expect(validateConfig({})).rejects.toThrow(); - await expect(validateConfig({ clientId })).rejects.toThrow(); - await expect(validateConfig({ clientSecret })).rejects.toThrow(); - }); - }); - - describe('getAuthorizationUri', () => { - it('should get a valid authorizationUri with redirectUri and state', async () => { - const redirectUri = 'http://localhost:3000/callback'; - const state = 'some_state'; - const authorizationUri = await getAuthorizationUri(redirectUri, state); - - const encodedRedirectUri = encodeURIComponent(redirectUri); - expect(authorizationUri).toEqual( - `${authorizationEndpoint}?client_id=${clientId}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=email%2Cpublic_profile&state=${state}` - ); - }); - }); - - describe('getAccessToken', () => { - it('should get an accessToken by exchanging with code', async () => { - nock(accessTokenEndpoint) - .get('') - .query({ - code, - client_id: clientId, - client_secret: clientSecret, - redirect_uri: dummyRedirectUri, - }) - .reply(200, { - access_token: 'access_token', - scope: 'scope', - token_type: 'token_type', - }); - - const { accessToken } = await getAccessToken(code, dummyRedirectUri); - expect(accessToken).toEqual('access_token'); - }); - - it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { - nock(accessTokenEndpoint) - .get('') - .query({ - code, - client_id: clientId, - client_secret: clientSecret, - redirect_uri: dummyRedirectUri, - }) - .reply(200, {}); - - await expect(getAccessToken(code, dummyRedirectUri)).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) - ); - }); - }); - - describe('getUserInfo', () => { - it('should get valid SocialUserInfo', async () => { - const avatar = 'https://github.com/images/error/octocat_happy.gif'; - nock(userInfoEndpoint) - .get('') - .query({ fields }) - .reply(200, { - id: '1234567890', - name: 'monalisa octocat', - email: 'octocat@facebook.com', - picture: { data: { url: avatar } }, - }); - - const socialUserInfo = await getUserInfo({ accessToken: code }); - expect(socialUserInfo).toMatchObject({ - id: '1234567890', - avatar, - name: 'monalisa octocat', - email: 'octocat@facebook.com', - }); - }); - - it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => { - nock(userInfoEndpoint).get('').query({ fields }).reply(400); - await expect(getUserInfo({ accessToken: code })).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) - ); - }); - - it('throws unrecognized error', async () => { - nock(userInfoEndpoint).get('').reply(500); - await expect(getUserInfo({ accessToken: code })).rejects.toThrow(); - }); - }); -}); diff --git a/packages/core/src/connectors/facebook/index.ts b/packages/core/src/connectors/facebook/index.ts deleted file mode 100644 index 640d413a7..000000000 --- a/packages/core/src/connectors/facebook/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Reference: Manually Build a Login Flow - * https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow - */ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import got, { RequestError as GotRequestError } from 'got'; -import { stringify } from 'query-string'; -import { z } from 'zod'; - -import { - accessTokenEndpoint, - authorizationEndpoint, - scope, - userInfoEndpoint, -} from '@/connectors/facebook/constant'; -import { - ConnectorError, - ConnectorErrorCodes, - ConnectorMetadata, - ConnectorType, - GetAccessToken, - GetAuthorizationUri, - GetUserInfo, - ValidateConfig, -} from '@/connectors/types'; -import { getConnectorConfig, getConnectorRequestTimeout } from '@/connectors/utilities'; -import assertThat from '@/utils/assert-that'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'facebook', - type: ConnectorType.Social, - name: { - en: 'Sign In with Facebook', - 'zh-CN': 'Facebook 登录', - }, - // TODO: add the real logo URL (LOG-1823) - logo: './logo.png', - description: { - en: 'Sign In with Facebook', - 'zh-CN': 'Facebook 登录', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -const facebookConfigGuard = z.object({ - clientId: z.string(), - clientSecret: z.string(), -}); - -type FacebookConfig = z.infer; - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = facebookConfigGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { - const config = await getConnectorConfig(metadata.id); - - return `${authorizationEndpoint}?${stringify({ - client_id: config.clientId, - redirect_uri: redirectUri, - response_type: 'code', - state, - scope, // Only support fixed scope for v1. - })}`; -}; - -export const getAccessToken: GetAccessToken = async (code, redirectUri) => { - type AccessTokenResponse = { - access_token: string; - token_type: string; - expires_in: number; - }; - - const { clientId: client_id, clientSecret: client_secret } = - await getConnectorConfig(metadata.id); - - const { access_token: accessToken } = await got - .get(accessTokenEndpoint, { - searchParams: { - code, - client_id, - client_secret, - redirect_uri: redirectUri, - }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); - - return { accessToken }; -}; - -export const getUserInfo: GetUserInfo = async (accessTokenObject) => { - type UserInfoResponse = { - id: string; - email?: string; - name?: string; - picture?: { data: { url: string } }; - }; - - const { accessToken } = accessTokenObject; - - try { - const { id, email, name, picture } = await got - .get(userInfoEndpoint, { - headers: { - authorization: `Bearer ${accessToken}`, - }, - searchParams: { - fields: 'id,name,email,picture', - }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - return { - id, - avatar: picture?.data.url, - email, - name, - }; - } catch (error: unknown) { - if (error instanceof GotRequestError && error.response?.statusCode === 400) { - throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); - } - throw error; - } -}; diff --git a/packages/core/src/connectors/github/README.md b/packages/core/src/connectors/github/README.md deleted file mode 100644 index 4f9bb63b2..000000000 --- a/packages/core/src/connectors/github/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# GitHub Connector - -The GitHub connector provides the ability to easily integrate GitHub’s OAuth App. - -Official guide on OAuth App: [https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app) - -## Prerequisites - -A GitHub account, both personal and organization are OK. - -## Configuration On GitHub - -Follow the official guide above to create an OAuth App step by step, notice on those fields: - -### 1. Homepage URL - -Simply input your website’s url like `www.example.com` - -### 2. Authorization callback URL - -also `www.example.com` - -## Settings - -Name | Type | Description | Required ------------- | ------------ | ------------- | --- -clientId | string | The client ID you received from GitHub when you created an OAuth App | YES -clientSecret | string | The client secret you received from GitHub when you created an OAuth App | YES diff --git a/packages/core/src/connectors/github/config-template.md b/packages/core/src/connectors/github/config-template.md deleted file mode 100644 index 081079e46..000000000 --- a/packages/core/src/connectors/github/config-template.md +++ /dev/null @@ -1,4 +0,0 @@ -{ - "clientId": "", - "clientSecret": "" -} diff --git a/packages/core/src/connectors/github/constant.ts b/packages/core/src/connectors/github/constant.ts deleted file mode 100644 index 6e467cfd0..000000000 --- a/packages/core/src/connectors/github/constant.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const authorizationEndpoint = 'https://github.com/login/oauth/authorize'; -export const scope = 'read:user'; -export const accessTokenEndpoint = 'https://github.com/login/oauth/access_token'; -export const userInfoEndpoint = 'https://api.github.com/user'; diff --git a/packages/core/src/connectors/github/index.test.ts b/packages/core/src/connectors/github/index.test.ts deleted file mode 100644 index 3305bc6f9..000000000 --- a/packages/core/src/connectors/github/index.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import nock from 'nock'; - -import { getAccessToken, getAuthorizationUri, validateConfig, getUserInfo } from '.'; -import { ConnectorError, ConnectorErrorCodes } from '../types'; -import { getConnectorConfig } from '../utilities'; -import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant'; - -jest.mock('../utilities'); - -beforeAll(() => { - (getConnectorConfig as jest.MockedFunction).mockResolvedValue({ - clientId: '', - clientSecret: '', - }); -}); - -describe('getAuthorizationUri', () => { - it('should get a valid uri by redirectUri and state', async () => { - const authorizationUri = await getAuthorizationUri( - 'http://localhost:3000/callback', - 'some_state' - ); - expect(authorizationUri).toEqual( - `${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=read%3Auser&state=some_state` - ); - }); -}); - -describe('getAccessToken', () => { - it('should get an accessToken by exchanging with code', async () => { - nock(accessTokenEndpoint).post('').reply(200, { - access_token: 'access_token', - scope: 'scope', - token_type: 'token_type', - }); - const { accessToken } = await getAccessToken('code', 'dummyRedirectUri'); - expect(accessToken).toEqual('access_token'); - }); - it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { - nock(accessTokenEndpoint).post('').reply(200, {}); - await expect(getAccessToken('code', 'dummyRedirectUri')).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) - ); - }); -}); - -describe('validateConfig', () => { - it('should pass on valid config', async () => { - await expect( - validateConfig({ clientId: 'clientId', clientSecret: 'clientSecret' }) - ).resolves.not.toThrow(); - }); - it('should throw on empty config', async () => { - await expect(validateConfig({})).rejects.toThrowError(); - }); - it('should throw when missing clientSecret', async () => { - await expect(validateConfig({ clientId: 'clientId' })).rejects.toThrowError(); - }); -}); - -describe('getUserInfo', () => { - it('should get valid SocialUserInfo', async () => { - nock(userInfoEndpoint).get('').reply(200, { - id: 1, - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - name: 'monalisa octocat', - email: 'octocat@github.com', - }); - const socialUserInfo = await getUserInfo({ accessToken: 'code' }); - expect(socialUserInfo).toMatchObject({ - id: '1', - avatar: 'https://github.com/images/error/octocat_happy.gif', - name: 'monalisa octocat', - email: 'octocat@github.com', - }); - }); - it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => { - nock(userInfoEndpoint).get('').reply(401); - await expect(getUserInfo({ accessToken: 'code' })).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) - ); - }); - it('throws unrecognized error', async () => { - nock(userInfoEndpoint).get('').reply(500); - await expect(getUserInfo({ accessToken: 'code' })).rejects.toThrow(); - }); -}); diff --git a/packages/core/src/connectors/github/index.ts b/packages/core/src/connectors/github/index.ts deleted file mode 100644 index 824d36daf..000000000 --- a/packages/core/src/connectors/github/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import got, { RequestError as GotRequestError } from 'got'; -import { stringify } from 'query-string'; -import { z } from 'zod'; - -import assertThat from '@/utils/assert-that'; - -import { - ConnectorMetadata, - GetAccessToken, - GetAuthorizationUri, - ValidateConfig, - GetUserInfo, - ConnectorType, - ConnectorError, - ConnectorErrorCodes, -} from '../types'; -import { getConnectorConfig, getConnectorRequestTimeout } from '../utilities'; -import { authorizationEndpoint, accessTokenEndpoint, scope, userInfoEndpoint } from './constant'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'github', - type: ConnectorType.Social, - name: { - en: 'Sign In with GitHub', - 'zh-CN': 'GitHub登录', - }, - logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png', - description: { - en: 'Sign In with GitHub', - 'zh-CN': 'GitHub登录', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -const githubConfigGuard = z.object({ - clientId: z.string(), - clientSecret: z.string(), -}); - -type GithubConfig = z.infer; - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = githubConfigGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { - const config = await getConnectorConfig(metadata.id); - - return `${authorizationEndpoint}?${stringify({ - client_id: config.clientId, - redirect_uri: redirectUri, - state, - scope, // Only support fixed scope for v1. - })}`; -}; - -export const getAccessToken: GetAccessToken = async (code) => { - type AccessTokenResponse = { - access_token: string; - scope: string; - token_type: string; - }; - - const { clientId: client_id, clientSecret: client_secret } = - await getConnectorConfig(metadata.id); - - const { access_token: accessToken } = await got - .post({ - url: accessTokenEndpoint, - json: { - client_id, - client_secret, - code, - }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); - - return { accessToken }; -}; - -export const getUserInfo: GetUserInfo = async (accessTokenObject) => { - type UserInfoResponse = { - id: number; - avatar_url?: string; - email?: string; - name?: string; - }; - - const { accessToken } = accessTokenObject; - - try { - const { - id, - avatar_url: avatar, - email, - name, - } = await got - .get(userInfoEndpoint, { - headers: { - authorization: `token ${accessToken}`, - }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - return { - id: String(id), - avatar, - email, - name, - }; - } catch (error: unknown) { - if (error instanceof GotRequestError && error.response?.statusCode === 401) { - throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); - } - throw error; - } -}; diff --git a/packages/core/src/connectors/google/README.md b/packages/core/src/connectors/google/README.md deleted file mode 100644 index dcb92c2c7..000000000 --- a/packages/core/src/connectors/google/README.md +++ /dev/null @@ -1,2 +0,0 @@ -### Google Social Connector README -placeholder diff --git a/packages/core/src/connectors/google/config-template.md b/packages/core/src/connectors/google/config-template.md deleted file mode 100644 index 081079e46..000000000 --- a/packages/core/src/connectors/google/config-template.md +++ /dev/null @@ -1,4 +0,0 @@ -{ - "clientId": "", - "clientSecret": "" -} diff --git a/packages/core/src/connectors/google/constant.ts b/packages/core/src/connectors/google/constant.ts deleted file mode 100644 index 513dcf0c8..000000000 --- a/packages/core/src/connectors/google/constant.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const authorizationEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth'; -export const accessTokenEndpoint = 'https://oauth2.googleapis.com/token'; -export const userInfoEndpoint = 'https://openidconnect.googleapis.com/v1/userinfo'; -export const scope = 'openid profile email'; diff --git a/packages/core/src/connectors/google/index.test.ts b/packages/core/src/connectors/google/index.test.ts deleted file mode 100644 index 9ef2c9afd..000000000 --- a/packages/core/src/connectors/google/index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import nock from 'nock'; - -import { validateConfig, getAuthorizationUri, getAccessToken, getUserInfo } from '.'; -import { ConnectorError, ConnectorErrorCodes } from '../types'; -import { getConnectorConfig } from '../utilities'; -import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant'; - -jest.mock('../utilities'); - -beforeAll(() => { - (getConnectorConfig as jest.MockedFunction).mockResolvedValue({ - clientId: '', - clientSecret: '', - }); -}); - -describe('google connector', () => { - describe('validateConfig', () => { - it('should pass on valid config', async () => { - await expect( - validateConfig({ clientId: 'clientId', clientSecret: 'clientSecret' }) - ).resolves.not.toThrow(); - }); - - it('should throw on invalid config', async () => { - await expect(validateConfig({})).rejects.toThrow(); - await expect(validateConfig({ clientId: 'clientId' })).rejects.toThrow(); - await expect(validateConfig({ clientSecret: 'clientSecret' })).rejects.toThrow(); - }); - }); - - describe('getAuthorizationUri', () => { - it('should get a valid authorizationUri with redirectUri and state', async () => { - const authorizationUri = await getAuthorizationUri( - 'http://localhost:3000/callback', - 'some_state' - ); - expect(authorizationUri).toEqual( - `${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code&scope=openid%20profile%20email&state=some_state` - ); - }); - }); - - describe('getAccessToken', () => { - it('should get an accessToken by exchanging with code', async () => { - nock(accessTokenEndpoint).post('').reply(200, { - access_token: 'access_token', - scope: 'scope', - token_type: 'token_type', - }); - const { accessToken } = await getAccessToken('code', 'dummyRedirectUri'); - expect(accessToken).toEqual('access_token'); - }); - - it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { - nock(accessTokenEndpoint).post('').reply(200, {}); - await expect(getAccessToken('code', 'dummyRedirectUri')).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) - ); - }); - }); - - describe('getUserInfo', () => { - it('should get valid SocialUserInfo', async () => { - nock(userInfoEndpoint).post('').reply(200, { - sub: '1234567890', - name: 'monalisa octocat', - given_name: 'monalisa', - family_name: 'octocat', - picture: 'https://github.com/images/error/octocat_happy.gif', - email: 'octocat@google.com', - email_verified: true, - locale: 'en', - }); - const socialUserInfo = await getUserInfo({ accessToken: 'code' }); - expect(socialUserInfo).toMatchObject({ - id: '1234567890', - avatar: 'https://github.com/images/error/octocat_happy.gif', - name: 'monalisa octocat', - email: 'octocat@google.com', - }); - }); - - it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => { - nock(userInfoEndpoint).post('').reply(401); - await expect(getUserInfo({ accessToken: 'code' })).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) - ); - }); - - it('throws unrecognized error', async () => { - nock(userInfoEndpoint).post('').reply(500); - await expect(getUserInfo({ accessToken: 'code' })).rejects.toThrow(); - }); - }); -}); diff --git a/packages/core/src/connectors/google/index.ts b/packages/core/src/connectors/google/index.ts deleted file mode 100644 index 5a16b07fb..000000000 --- a/packages/core/src/connectors/google/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * The Implementation of OpenID Connect of Google Identity Platform. - * https://developers.google.com/identity/protocols/oauth2/openid-connect - */ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import { conditional } from '@silverhand/essentials'; -import got, { RequestError as GotRequestError } from 'got'; -import { stringify } from 'query-string'; -import { z } from 'zod'; - -import assertThat from '@/utils/assert-that'; - -import { - ConnectorError, - ConnectorErrorCodes, - ConnectorMetadata, - ConnectorType, - GetAccessToken, - GetAuthorizationUri, - GetUserInfo, - ValidateConfig, -} from '../types'; -import { getConnectorConfig, getConnectorRequestTimeout } from '../utilities'; -import { accessTokenEndpoint, authorizationEndpoint, scope, userInfoEndpoint } from './constant'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'google', - type: ConnectorType.Social, - name: { - en: 'Sign In with Google', - 'zh-CN': 'Google登录', - }, - // TODO: add the real logo URL (LOG-1823) - logo: './logo.png', - description: { - en: 'Sign In with Google', - 'zh-CN': 'Google登录', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -const googleConfigGuard = z.object({ - clientId: z.string(), - clientSecret: z.string(), -}); - -type GoogleConfig = z.infer; - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = googleConfigGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { - const config = await getConnectorConfig(metadata.id); - - return `${authorizationEndpoint}?${stringify({ - client_id: config.clientId, - redirect_uri: redirectUri, - response_type: 'code', - state, - scope, - })}`; -}; - -export const getAccessToken: GetAccessToken = async (code, redirectUri) => { - type AccessTokenResponse = { - access_token: string; - scope: string; - token_type: string; - }; - - const { clientId, clientSecret } = await getConnectorConfig(metadata.id); - - // Note:Need to decodeURIComponent on code - // https://stackoverflow.com/questions/51058256/google-api-node-js-invalid-grant-malformed-auth-code - const { access_token: accessToken } = await got - .post(accessTokenEndpoint, { - form: { - code: decodeURIComponent(code), - client_id: clientId, - client_secret: clientSecret, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }, - timeout: await getConnectorRequestTimeout(), - followRedirect: true, - }) - .json(); - - assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); - - return { accessToken }; -}; - -export const getUserInfo: GetUserInfo = async (accessTokenObject) => { - type UserInfoResponse = { - sub: string; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; - email?: string; - email_verified?: boolean; - locale?: string; - }; - - const { accessToken } = accessTokenObject; - - try { - const { - sub: id, - picture: avatar, - email, - email_verified, - name, - } = await got - .post(userInfoEndpoint, { - headers: { - authorization: `Bearer ${accessToken}`, - }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - return { - id, - avatar, - email: conditional(email_verified && email), - name, - }; - } catch (error: unknown) { - if (error instanceof GotRequestError && error.response?.statusCode === 401) { - throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); - } - throw error; - } -}; diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts index 94001d08b..85bfc4d55 100644 --- a/packages/core/src/connectors/index.ts +++ b/packages/core/src/connectors/index.ts @@ -1,25 +1,28 @@ +import { AlipayConnector } from '@logto/connector-alipay'; +import { AliyunDmConnector } from '@logto/connector-aliyun-dm'; +import { AliyunSmsConnector } from '@logto/connector-aliyun-sms'; +import { FacebookConnector } from '@logto/connector-facebook'; +import { GithubConnector } from '@logto/connector-github'; +import { GoogleConnector } from '@logto/connector-google'; +import { SmsConnector, EmailConnector, SocialConnector } from '@logto/connector-types'; +import { WeChatConnector } from '@logto/connector-wechat'; +import { WeChatNativeConnector } from '@logto/connector-wechat-native'; + import RequestError from '@/errors/RequestError'; import { findAllConnectors, findConnectorById, insertConnector } from '@/queries/connector'; -import * as Alipay from './alipay'; -import * as AliyunDM from './aliyun-dm'; -import * as AliyunSMS from './aliyun-sms'; -import * as Facebook from './facebook'; -import * as GitHub from './github'; -import * as Google from './google'; import { ConnectorInstance, ConnectorType, IConnector, SocialConnectorInstance } from './types'; -import * as WeChat from './wechat'; -import * as WeChatNative from './wechat-native'; +import { getConnectorConfig } from './utilities'; const allConnectors: IConnector[] = [ - Alipay, - AliyunDM, - AliyunSMS, - Facebook, - GitHub, - Google, - WeChat, - WeChatNative, + new AlipayConnector(getConnectorConfig) as SocialConnector, + new AliyunDmConnector(getConnectorConfig) as EmailConnector, + new AliyunSmsConnector(getConnectorConfig) as SmsConnector, + new FacebookConnector(getConnectorConfig) as SocialConnector, + new GithubConnector(getConnectorConfig) as SocialConnector, + new GoogleConnector(getConnectorConfig) as SocialConnector, + new WeChatConnector(getConnectorConfig) as SocialConnector, + new WeChatNativeConnector(getConnectorConfig) as SocialConnector, ]; export const getConnectorInstances = async (): Promise => { diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts index ebfac3031..a164ae49e 100644 --- a/packages/core/src/connectors/types.ts +++ b/packages/core/src/connectors/types.ts @@ -1,4 +1,5 @@ -import { ArbitraryObject, Connector, PasscodeType, ConnectorMetadata } from '@logto/schemas'; +import { SmsConnector, EmailConnector, SocialConnector } from '@logto/connector-types'; +import { Connector, PasscodeType } from '@logto/schemas'; import { z } from 'zod'; export { ConnectorType } from '@logto/schemas'; @@ -11,89 +12,14 @@ export type ConnectorInstance = | EmailConnectorInstance | SocialConnectorInstance; -export interface BaseConnector { - metadata: ConnectorMetadata; - validateConfig: ValidateConfig; -} - -export interface SmsConnector extends BaseConnector { - sendMessage: SmsSendMessageFunction; -} - export type SmsConnectorInstance = SmsConnector & { connector: Connector }; -export interface EmailConnector extends BaseConnector { - sendMessage: EmailSendMessageFunction; -} - export type EmailConnectorInstance = EmailConnector & { connector: Connector }; -export interface SocialConnector extends BaseConnector { - getAuthorizationUri: GetAuthorizationUri; - getAccessToken: GetAccessToken; - getUserInfo: GetUserInfo; -} - export type SocialConnectorInstance = SocialConnector & { connector: Connector }; -export type EmailMessageTypes = { - SignIn: { - code: string; - }; - Register: { - code: string; - }; - ForgotPassword: { - code: string; - }; - Test: Record; -}; - -type SmsMessageTypes = EmailMessageTypes; - -export type EmailSendMessageFunction = ( - address: string, - type: keyof EmailMessageTypes, - payload: EmailMessageTypes[typeof type] -) => Promise; - -export type SmsSendMessageFunction = ( - phone: string, - type: keyof SmsMessageTypes, - payload: SmsMessageTypes[typeof type] -) => Promise; - export type TemplateType = PasscodeType | 'Test'; -export enum ConnectorErrorCodes { - General, - InvalidConfig, - InvalidResponse, - TemplateNotFound, - SocialAuthCodeInvalid, - SocialAccessTokenInvalid, -} -export class ConnectorError extends Error { - public code: ConnectorErrorCodes; - - constructor(code: ConnectorErrorCodes, message?: string) { - super(message); - this.code = code; - } -} - -export type ValidateConfig = ( - config: T -) => Promise; - -export type GetAuthorizationUri = (redirectUri: string, state: string) => Promise; - -type AccessTokenObject = { accessToken: string } & Record; - -export type GetAccessToken = (code: string, redirectUri?: string) => Promise; - -export type GetUserInfo = (accessTokenObject: AccessTokenObject) => Promise; - export const socialUserInfoGuard = z.object({ id: z.string(), email: z.string().optional(), diff --git a/packages/core/src/connectors/utilities/aliyun.test.ts b/packages/core/src/connectors/utilities/aliyun.test.ts deleted file mode 100644 index e068d5a4c..000000000 --- a/packages/core/src/connectors/utilities/aliyun.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import got from 'got'; - -import { getSignature, request } from './aliyun'; - -jest.mock('got'); - -describe('getSignature', () => { - it('should get valid signature', () => { - const parameters = { - AccessKeyId: 'testid', - AccountName: "", - Action: 'SingleSendMail', - AddressType: '1', - Format: 'XML', - HtmlBody: '4', - RegionId: 'cn-hangzhou', - ReplyToAddress: 'true', - SignatureMethod: 'HMAC-SHA1', - SignatureNonce: 'c1b2c332-4cfb-4a0f-b8cc-ebe622aa0a5c', - SignatureVersion: '1.0', - Subject: '3', - TagName: '2', - Timestamp: '2016-10-20T06:27:56Z', - ToAddress: '1@test.com', - Version: '2015-11-23', - }; - const signature = getSignature(parameters, 'testsecret', 'POST'); - expect(signature).toEqual('llJfXJjBW3OacrVgxxsITgYaYm0='); - }); -}); - -describe('request', () => { - it('should call axios.post with extended params', async () => { - const parameters = { - AccessKeyId: 'testid', - AccountName: "", - Action: 'SingleSendMail', - AddressType: '1', - Format: 'XML', - HtmlBody: '4', - RegionId: 'cn-hangzhou', - ReplyToAddress: 'true', - Subject: '3', - TagName: '2', - ToAddress: '1@test.com', - Version: '2015-11-23', - SignatureMethod: 'HMAC-SHA1', - SignatureVersion: '1.0', - }; - await request('test-endpoint', parameters, 'testsecret'); - const calledData = (got.post as jest.MockedFunction).mock.calls[0]; - expect(calledData).not.toBeUndefined(); - const payload = calledData?.[0].form as URLSearchParams; - expect(payload.get('AccessKeyId')).toEqual('testid'); - expect(payload.get('Timestamp')).not.toBeNull(); - expect(payload.get('SignatureNonce')).not.toBeNull(); - expect(payload.get('Signature')).not.toBeNull(); - }); -}); diff --git a/packages/core/src/connectors/utilities/aliyun.ts b/packages/core/src/connectors/utilities/aliyun.ts deleted file mode 100644 index 190cd2849..000000000 --- a/packages/core/src/connectors/utilities/aliyun.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createHmac } from 'crypto'; - -import { has } from '@silverhand/essentials'; -import got from 'got'; - -// Aliyun has special escape rules. -// https://help.aliyun.com/document_detail/29442.html -const escaper = (string_: string) => - encodeURIComponent(string_) - .replace(/\*/g, '%2A') - .replace(/'/g, '%27') - .replace(/!/g, '%21') - .replace(/"/g, '%22') - .replace(/\(/g, '%28') - .replace(/\)/g, '%29') - .replace(/\+/, '%2B'); - -export const getSignature = ( - parameters: Record, - secret: string, - method: string -) => { - const canonicalizedQuery = Object.keys(parameters) - .slice() - .sort() - .map((key) => { - const value = parameters[key]; - - return value === undefined ? '' : `${escaper(key)}=${escaper(value)}`; - }) - .filter(Boolean) - .join('&'); - - const stringToSign = `${method.toUpperCase()}&${escaper('/')}&${escaper(canonicalizedQuery)}`; - - return createHmac('sha1', `${secret}&`).update(stringToSign).digest('base64'); -}; - -export interface PublicParameters { - AccessKeyId: string; - Format?: string; // 'json' or 'xml', default: 'json' - RegionId?: string; // 'cn-hangzhou' | 'ap-southeast-1' | 'ap-southeast-2' - Signature?: string; - SignatureMethod?: string; - SignatureNonce?: string; - SignatureVersion?: string; - Timestamp?: string; - Version?: string; -} - -export const request = async ( - url: string, - parameters: PublicParameters & Record, - accessKeySecret: string -) => { - const finalParameters: Record = { - ...parameters, - SignatureNonce: String(Math.random()), - Timestamp: new Date().toISOString(), - }; - const signature = getSignature(finalParameters, accessKeySecret, 'POST'); - - const payload = new URLSearchParams(); - - for (const key in finalParameters) { - if (has(finalParameters, key)) { - const value = finalParameters[key]; - - if (value !== undefined) { - payload.append(key, value); - } - } - } - - payload.append('Signature', signature); - - return got.post({ - url, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - form: payload, - }); -}; diff --git a/packages/core/src/connectors/utilities/index.ts b/packages/core/src/connectors/utilities/index.ts index 96a6105f7..86dafc805 100644 --- a/packages/core/src/connectors/utilities/index.ts +++ b/packages/core/src/connectors/utilities/index.ts @@ -1,5 +1,4 @@ import { ArbitraryObject } from '@logto/schemas'; -import dayjs from 'dayjs'; import { findConnectorById, updateConnector } from '@/queries/connector'; @@ -18,11 +17,3 @@ export const updateConnectorConfig = async ( set: { config }, }); }; - -const connectorRequestTimeout = 5000; - -export const getConnectorRequestTimeout = async (): Promise => connectorRequestTimeout; - -export const getFormattedDate = (): string => { - return dayjs().format('YYYY-MM-DD HH:mm:ss'); -}; diff --git a/packages/core/src/connectors/wechat-native/constant.ts b/packages/core/src/connectors/wechat-native/constant.ts deleted file mode 100644 index ededc3985..000000000 --- a/packages/core/src/connectors/wechat-native/constant.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const authorizationEndpoint = 'https://wechat.native/'; -export const scope = 'snsapi_userinfo'; diff --git a/packages/core/src/connectors/wechat-native/index.test.ts b/packages/core/src/connectors/wechat-native/index.test.ts deleted file mode 100644 index 93e4b9897..000000000 --- a/packages/core/src/connectors/wechat-native/index.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getAuthorizationUri } from '.'; -import { getConnectorConfig } from '../utilities'; -import { authorizationEndpoint } from './constant'; - -jest.mock('../utilities'); - -beforeAll(() => { - (getConnectorConfig as jest.MockedFunction).mockResolvedValue({ - appId: '', - appSecret: '', - }); -}); - -describe('getAuthorizationUri', () => { - it('should get a valid uri by redirectUri and state', async () => { - const authorizationUri = await getAuthorizationUri( - 'http://localhost:3001/callback', - 'some_state' - ); - expect(authorizationUri).toEqual( - `${authorizationEndpoint}?appid=%3Capp-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&scope=snsapi_userinfo&state=some_state` - ); - }); -}); diff --git a/packages/core/src/connectors/wechat-native/index.ts b/packages/core/src/connectors/wechat-native/index.ts deleted file mode 100644 index c4d916a12..000000000 --- a/packages/core/src/connectors/wechat-native/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * The Implementation of OpenID Connect of WeChat Web Open Platform. - * https://developers.weixin.qq.com/doc/oplatform/Mobile_App/WeChat_Login/Development_Guide.html - */ - -import { stringify } from 'query-string'; - -import { metadata as weChatWebMetadata, WeChatConfig } from '@/connectors/wechat'; - -import { ConnectorMetadata, GetAuthorizationUri } from '../types'; -import { getConnectorConfig } from '../utilities'; -import { authorizationEndpoint, scope } from './constant'; - -export { validateConfig, getAccessToken, getUserInfo } from '@/connectors/wechat'; - -export const metadata: ConnectorMetadata = { - ...weChatWebMetadata, - id: 'wechat-native', -}; - -export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { - const { appId } = await getConnectorConfig(metadata.id); - - return `${authorizationEndpoint}?${stringify({ - appid: appId, - redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret} - scope, - state, - })}`; -}; diff --git a/packages/core/src/connectors/wechat/README.md b/packages/core/src/connectors/wechat/README.md deleted file mode 100644 index 026f6ddaf..000000000 --- a/packages/core/src/connectors/wechat/README.md +++ /dev/null @@ -1,2 +0,0 @@ -### WeChat Social Connector README -placeholder diff --git a/packages/core/src/connectors/wechat/config-template.md b/packages/core/src/connectors/wechat/config-template.md deleted file mode 100644 index f458087c9..000000000 --- a/packages/core/src/connectors/wechat/config-template.md +++ /dev/null @@ -1,4 +0,0 @@ -{ - "appId": "", - "appSecret": "" -} diff --git a/packages/core/src/connectors/wechat/constant.ts b/packages/core/src/connectors/wechat/constant.ts deleted file mode 100644 index 3eb401925..000000000 --- a/packages/core/src/connectors/wechat/constant.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const authorizationEndpoint = 'https://open.weixin.qq.com/connect/qrconnect'; -export const accessTokenEndpoint = 'https://api.weixin.qq.com/sns/oauth2/access_token'; -export const userInfoEndpoint = 'https://api.weixin.qq.com/sns/userinfo'; -export const scope = 'snsapi_login'; diff --git a/packages/core/src/connectors/wechat/index.test.ts b/packages/core/src/connectors/wechat/index.test.ts deleted file mode 100644 index db9494b2c..000000000 --- a/packages/core/src/connectors/wechat/index.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import nock from 'nock'; - -import { getAccessToken, getAuthorizationUri, validateConfig, getUserInfo } from '.'; -import { ConnectorError, ConnectorErrorCodes } from '../types'; -import { getConnectorConfig } from '../utilities'; -import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant'; - -jest.mock('../utilities'); - -beforeAll(() => { - (getConnectorConfig as jest.MockedFunction).mockResolvedValue({ - appId: '', - appSecret: '', - }); -}); - -describe('getAuthorizationUri', () => { - it('should get a valid uri by redirectUri and state', async () => { - const authorizationUri = await getAuthorizationUri( - 'http://localhost:3001/callback', - 'some_state' - ); - expect(authorizationUri).toEqual( - `${authorizationEndpoint}?appid=%3Capp-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&response_type=code&scope=snsapi_login&state=some_state` - ); - }); -}); - -describe('getAccessToken', () => { - afterEach(() => { - nock.cleanAll(); - }); - - const accessTokenEndpointUrl = new URL(accessTokenEndpoint); - const parameters = new URLSearchParams({ - appid: '', - secret: '', - code: 'code', - grant_type: 'authorization_code', - }); - - it('should get an accessToken by exchanging with code', async () => { - nock(accessTokenEndpointUrl.origin) - .get(accessTokenEndpointUrl.pathname) - .query(parameters) - .reply(200, { - access_token: 'access_token', - openid: 'openid', - }); - const { accessToken, openid } = await getAccessToken('code'); - expect(accessToken).toEqual('access_token'); - expect(openid).toEqual('openid'); - }); - - it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { - nock(accessTokenEndpointUrl.origin) - .get(accessTokenEndpointUrl.pathname) - .query(parameters) - .reply(200, {}); - await expect(getAccessToken('code')).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) - ); - }); -}); - -describe('validateConfig', () => { - it('should pass on valid config', async () => { - await expect(validateConfig({ appId: 'appId', appSecret: 'appSecret' })).resolves.not.toThrow(); - }); - it('should throw on empty config', async () => { - await expect(validateConfig({})).rejects.toThrowError(); - }); - it('should throw when missing appSecret', async () => { - await expect(validateConfig({ appId: 'appId' })).rejects.toThrowError(); - }); -}); - -describe('getUserInfo', () => { - afterEach(() => { - nock.cleanAll(); - }); - - const userInfoEndpointUrl = new URL(userInfoEndpoint); - const parameters = new URLSearchParams({ access_token: 'accessToken', openid: 'openid' }); - - it('should get valid SocialUserInfo', async () => { - nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, { - unionid: 'this_is_an_arbitrary_wechat_union_id', - headimgurl: 'https://github.com/images/error/octocat_happy.gif', - nickname: 'wechat bot', - }); - const socialUserInfo = await getUserInfo({ accessToken: 'accessToken', openid: 'openid' }); - expect(socialUserInfo).toMatchObject({ - id: 'this_is_an_arbitrary_wechat_union_id', - avatar: 'https://github.com/images/error/octocat_happy.gif', - name: 'wechat bot', - }); - }); - - it('throws SocialAccessTokenInvalid error if errcode is 40001', async () => { - nock(userInfoEndpointUrl.origin) - .get(userInfoEndpointUrl.pathname) - .query(parameters) - .reply(200, { errcode: 40_001 }); - await expect( - getUserInfo({ accessToken: 'accessToken', openid: 'openid' }) - ).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)); - }); - - it('throws unrecognized error', async () => { - nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500); - await expect(getUserInfo({ accessToken: 'accessToken', openid: 'openid' })).rejects.toThrow(); - }); - - it('throws Error if request failed and errcode is not 40001', async () => { - nock(userInfoEndpointUrl.origin) - .get(userInfoEndpointUrl.pathname) - .query(parameters) - .reply(200, { errcode: 40_003, errmsg: 'invalid openid' }); - await expect( - getUserInfo({ accessToken: 'accessToken', openid: 'openid' }) - ).rejects.toMatchError(new Error('invalid openid')); - }); - - it('throws SocialAccessTokenInvalid error if response code is 401', async () => { - nock(userInfoEndpointUrl.origin) - .get(userInfoEndpointUrl.pathname) - .query(new URLSearchParams({ access_token: 'accessToken' })) - .reply(401); - await expect(getUserInfo({ accessToken: 'accessToken' })).rejects.toMatchError( - new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) - ); - }); -}); diff --git a/packages/core/src/connectors/wechat/index.ts b/packages/core/src/connectors/wechat/index.ts deleted file mode 100644 index e2812e68c..000000000 --- a/packages/core/src/connectors/wechat/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * The Implementation of OpenID Connect of WeChat Web Open Platform. - * https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html - */ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -import got, { RequestError as GotRequestError } from 'got'; -import { stringify } from 'query-string'; -import { z } from 'zod'; - -import assertThat from '@/utils/assert-that'; - -import { - ConnectorMetadata, - GetAccessToken, - GetAuthorizationUri, - ValidateConfig, - GetUserInfo, - ConnectorType, - ConnectorError, - ConnectorErrorCodes, -} from '../types'; -import { getConnectorConfig, getConnectorRequestTimeout } from '../utilities'; -import { authorizationEndpoint, accessTokenEndpoint, userInfoEndpoint, scope } from './constant'; - -// eslint-disable-next-line unicorn/prefer-module -const currentPath = __dirname; -const pathToReadmeFile = path.join(currentPath, 'README.md'); -const pathToConfigTemplate = path.join(currentPath, 'config-template.md'); -const readmeContentFallback = 'Please check README.md file directory.'; -const configTemplateFallback = 'Please check config-template.md file directory.'; - -export const metadata: ConnectorMetadata = { - id: 'wechat', - type: ConnectorType.Social, - name: { - en: 'Sign In with WeChat', - 'zh-CN': '微信登录', - }, - // TODO: add the real logo URL (LOG-1823) - logo: './logo.png', - description: { - en: 'Sign In with WeChat', - 'zh-CN': '微信登录', - }, - readme: existsSync(pathToReadmeFile) - ? readFileSync(pathToReadmeFile, 'utf8') - : readmeContentFallback, - configTemplate: existsSync(pathToConfigTemplate) - ? readFileSync(pathToConfigTemplate, 'utf-8') - : configTemplateFallback, -}; - -// As creating a WeChat Web/Mobile application needs a real App or Website record, the real test is temporarily not finished. -// TODO: test with our own wechat mobile/web application (LOG-1910), already tested with other verified wechat web application - -const weChatConfigGuard = z.object({ appId: z.string(), appSecret: z.string() }); - -export type WeChatConfig = z.infer; - -export const validateConfig: ValidateConfig = async (config: unknown) => { - const result = weChatConfigGuard.safeParse(config); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); - } -}; - -export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { - const { appId } = await getConnectorConfig(metadata.id); - - return `${authorizationEndpoint}?${stringify({ - appid: appId, - redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret} - response_type: 'code', - scope, - state, - })}`; -}; - -export const getAccessToken: GetAccessToken = async (code) => { - type AccessTokenResponse = { - access_token?: string; - openid?: string; - expires_in?: number; // In seconds - refresh_token?: string; - scope?: string; - errcode?: number; - }; - - const config = await getConnectorConfig(metadata.id); - const { appId: appid, appSecret: secret } = config; - - const { - access_token: accessToken, - openid, - errcode, - } = await got - .get(accessTokenEndpoint, { - searchParams: { appid, secret, code, grant_type: 'authorization_code' }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - assertThat( - errcode !== 40_029 && accessToken && openid, - new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) - ); - - return { accessToken, openid }; -}; - -export const getUserInfo: GetUserInfo = async (accessTokenObject) => { - type UserInfoResponse = { - unionid?: string; - headimgurl?: string; - nickname?: string; - errcode?: number; - errmsg?: string; - }; - - const { accessToken, openid } = accessTokenObject; - - try { - const { unionid, headimgurl, nickname, errcode, errmsg } = await got - .get(userInfoEndpoint, { - searchParams: { access_token: accessToken, openid }, - timeout: await getConnectorRequestTimeout(), - }) - .json(); - - if (!openid || errcode || errmsg) { - // 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily to - // be the return value from getAccessToken per testing. - // In another word, 'openid' is required but the response of getUserInfo is consistent as long as - // access_token is valid. - // We are expecting to get 41009 'missing openid' response according to the developers doc, but the - // fact is that we still got 40001 'invalid credentials' response. - if (errcode === 40_001) { - throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); - } - - throw new Error(errmsg); - } - - return { id: unionid ?? openid, avatar: headimgurl, name: nickname }; - } catch (error: unknown) { - if (error instanceof GotRequestError && error.response?.statusCode === 401) { - throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); - } - - throw error; - } -}; diff --git a/packages/core/src/lib/passcode.test.ts b/packages/core/src/lib/passcode.test.ts index be7fd2a5e..c226d0ec2 100644 --- a/packages/core/src/lib/passcode.test.ts +++ b/packages/core/src/lib/passcode.test.ts @@ -1,7 +1,7 @@ +import { ConnectorType } from '@logto/connector-types'; import { Passcode, PasscodeType } from '@logto/schemas'; import { getConnectorInstanceByType } from '@/connectors'; -import { ConnectorType } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; import { deletePasscodesByIds, @@ -140,6 +140,7 @@ describe('sendPasscode', () => { }, sendMessage, validateConfig: jest.fn(), + getConfig: jest.fn(), }); const passcode: Passcode = { id: 'id', diff --git a/packages/core/src/middleware/koa-connector-error-handle.ts b/packages/core/src/middleware/koa-connector-error-handle.ts index 188a2db8a..def135165 100644 --- a/packages/core/src/middleware/koa-connector-error-handle.ts +++ b/packages/core/src/middleware/koa-connector-error-handle.ts @@ -1,6 +1,6 @@ +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-types'; import { Middleware } from 'koa'; -import { ConnectorError, ConnectorErrorCodes } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; export default function koaConnectorErrorHandler(): Middleware { diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector.test.ts index 46e2f01d6..6b0a2c60a 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector.test.ts @@ -1,15 +1,17 @@ /* eslint-disable max-lines */ +import { + ConnectorError, + ConnectorErrorCodes, + EmailMessageTypes, + ValidateConfig, +} from '@logto/connector-types'; import { Connector, ConnectorType } from '@logto/schemas'; import { mockConnectorInstanceList, mockConnectorList } from '@/__mocks__'; import { - ConnectorError, - ConnectorErrorCodes, ConnectorMetadata, EmailConnectorInstance, - EmailMessageTypes, SmsConnectorInstance, - ValidateConfig, } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; import { updateConnector } from '@/queries/connector'; @@ -572,6 +574,7 @@ describe('connector route', () => { configTemplate: 'config-template.md', }, validateConfig: jest.fn(), + getConfig: jest.fn(), sendMessage: async ( address: string, type: keyof EmailMessageTypes, @@ -618,6 +621,7 @@ describe('connector route', () => { configTemplate: 'config-template.md', }, validateConfig: jest.fn(), + getConfig: jest.fn(), sendMessage: async ( address: string, type: keyof EmailMessageTypes, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 670aac2a1..76dcf349c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -543,6 +543,15 @@ importers: packages/core: specifiers: + '@logto/connector-alipay': ^0.1.0 + '@logto/connector-aliyun-dm': ^0.1.0 + '@logto/connector-aliyun-sms': ^0.1.0 + '@logto/connector-facebook': ^0.1.0 + '@logto/connector-github': ^0.1.0 + '@logto/connector-google': ^0.1.0 + '@logto/connector-types': ^0.1.0 + '@logto/connector-wechat': ^0.1.0 + '@logto/connector-wechat-native': ^0.1.0 '@logto/jest-config': ^0.1.0 '@logto/phrases': ^0.1.0 '@logto/schemas': ^0.1.0 @@ -603,6 +612,15 @@ importers: typescript: ^4.6.2 zod: ^3.14.3 dependencies: + '@logto/connector-alipay': link:../connector-alipay + '@logto/connector-aliyun-dm': link:../connector-aliyun-dm + '@logto/connector-aliyun-sms': link:../connector-aliyun-sms + '@logto/connector-facebook': link:../connector-facebook + '@logto/connector-github': link:../connector-github + '@logto/connector-google': link:../connector-google + '@logto/connector-types': link:../connector-types + '@logto/connector-wechat': link:../connector-wechat + '@logto/connector-wechat-native': link:../connector-wechat-native '@logto/phrases': link:../phrases '@logto/schemas': link:../schemas '@silverhand/essentials': 1.1.2