diff --git a/packages/connector-alipay/jest.config.ts b/packages/connector-alipay/jest.config.ts new file mode 100644 index 000000000..d8ceb361c --- /dev/null +++ b/packages/connector-alipay/jest.config.ts @@ -0,0 +1,8 @@ +import { Config, merge } from '@logto/jest-config'; + +const config: Config.InitialOptions = merge({ + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-matcher-specific-error'], +}); + +export default config; diff --git a/packages/connector-alipay/package.json b/packages/connector-alipay/package.json new file mode 100644 index 000000000..bd9356807 --- /dev/null +++ b/packages/connector-alipay/package.json @@ -0,0 +1,61 @@ +{ + "name": "@logto/connector-alipay", + "version": "0.1.0", + "description": "Alipay implementation.", + "main": "lib/index.js", + "author": "Logto Team", + "license": "MPL-2.0", + "private": true, + "scripts": { + "preinstall": "npx only-allow pnpm", + "precommit": "lint-staged", + "copyfiles": "copyfiles -u 1 src/**/*.md lib", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json && pnpm run copyfiles", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint -- --format json --output-file report.json", + "dev": "rm -rf lib/ && pnpm run copyfiles && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"", + "test": "jest", + "test:coverage": "jest --coverage --silent", + "prepack": "pnpm build" + }, + "dependencies": { + "@logto/connector-types": "^0.1.0", + "@logto/jest-config": "^0.1.0", + "@logto/shared": "^0.1.0", + "@silverhand/essentials": "^1.1.0", + "dayjs": "^1.10.5", + "got": "^11.8.2", + "iconv-lite": "0.6.3", + "query-string": "^7.0.1", + "snakecase-keys": "^5.1.0", + "zod": "^3.14.3" + }, + "devDependencies": { + "@jest/types": "^27.5.1", + "@shopify/jest-koa-mocks": "^3.0.8", + "@silverhand/eslint-config": "^0.10.2", + "@silverhand/ts-config": "^0.10.2", + "@types/jest": "^27.4.1", + "@types/lodash.pick": "^4.4.6", + "@types/node": "^16.3.1", + "@types/supertest": "^2.0.11", + "copyfiles": "^2.4.1", + "eslint": "^8.10.0", + "jest": "^27.5.1", + "jest-matcher-specific-error": "^1.0.0", + "lint-staged": "^11.1.1", + "nock": "^13.2.2", + "prettier": "^2.3.2", + "supertest": "^6.2.2", + "ts-jest": "^27.1.1", + "tsc-watch": "^4.4.0", + "typescript": "^4.6.2" + }, + "engines": { + "node": "^16.0.0" + }, + "eslintConfig": { + "extends": "@silverhand" + }, + "prettier": "@silverhand/eslint-config/.prettierrc" +} diff --git a/packages/connector-alipay/src/README.md b/packages/connector-alipay/src/README.md new file mode 100644 index 000000000..03eaf5850 --- /dev/null +++ b/packages/connector-alipay/src/README.md @@ -0,0 +1,2 @@ +### Alipay Web Social Connector README +placeholder diff --git a/packages/connector-alipay/src/config-template.md b/packages/connector-alipay/src/config-template.md new file mode 100644 index 000000000..8ccabf50b --- /dev/null +++ b/packages/connector-alipay/src/config-template.md @@ -0,0 +1,5 @@ +{ + "appId": "", + "signType": "", + "privateKey": "" +} diff --git a/packages/connector-alipay/src/constant.ts b/packages/connector-alipay/src/constant.ts new file mode 100644 index 000000000..591e76918 --- /dev/null +++ b/packages/connector-alipay/src/constant.ts @@ -0,0 +1,42 @@ +import path from 'path'; + +import { ConnectorType, ConnectorMetadata } from '@logto/connector-types'; +import { getFileContents } from '@logto/shared'; + +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; + +// 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 defaultMetadata: 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: getFileContents(pathToReadmeFile, readmeContentFallback), + configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback), +}; + +export const defaultTimeout = 5000; diff --git a/packages/connector-alipay/src/index.test.ts b/packages/connector-alipay/src/index.test.ts new file mode 100644 index 000000000..4afdeaa25 --- /dev/null +++ b/packages/connector-alipay/src/index.test.ts @@ -0,0 +1,234 @@ +import { ConnectorError, ConnectorErrorCodes, GetConnectorConfig } from '@logto/connector-types'; +import nock from 'nock'; + +import { AlipayConnector } from '.'; +import { alipayEndpoint, authorizationEndpoint } from './constant'; +import { mockedAlipayConfig, mockedAlipayConfigWithValidPrivateKey } from './mock'; +import { AlipayConfig } from './types'; + +const getConnectorConfig = jest.fn() as GetConnectorConfig; + +const alipayMethods = new AlipayConnector(getConnectorConfig); + +describe('validateConfig', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + 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('getAuthorizationUri', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get a valid uri by redirectUri and state', async () => { + jest.spyOn(alipayMethods, 'getConfig').mockResolvedValueOnce(mockedAlipayConfig); + 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); + + it('should get an accessToken by exchanging with code', async () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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 response = await alipayMethods.getAccessToken('code'); + console.log(response); + const { accessToken } = response; + expect(accessToken).toEqual('access_token'); + }); + + it('should throw when accessToken is empty', async () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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 () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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); + + it('should get userInfo with accessToken', async () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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 () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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 () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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 () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin) + .post(alipayEndpointUrl.pathname) + .query(true) + .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 () => { + jest + .spyOn(alipayMethods, 'getConfig') + .mockResolvedValueOnce(mockedAlipayConfigWithValidPrivateKey); + nock(alipayEndpointUrl.origin).post(alipayEndpointUrl.pathname).query(true).reply(500); + + await expect(alipayMethods.getUserInfo({ accessToken: 'access_token' })).rejects.toThrow(); + }); +}); diff --git a/packages/connector-alipay/src/index.ts b/packages/connector-alipay/src/index.ts new file mode 100644 index 000000000..906672ce0 --- /dev/null +++ b/packages/connector-alipay/src/index.ts @@ -0,0 +1,157 @@ +/** + * 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 { + AccessTokenObject, + ConnectorError, + ConnectorErrorCodes, + ConnectorMetadata, + GetAccessToken, + GetAuthorizationUri, + GetUserInfo, + ValidateConfig, + SocialConnector, + GetConnectorConfig, +} from '@logto/connector-types'; +import { assert } from '@silverhand/essentials'; +import dayjs from 'dayjs'; +import got from 'got'; +import { stringify } from 'query-string'; + +import { + alipayEndpoint, + authorizationEndpoint, + methodForAccessToken, + methodForUserInfo, + scope, + defaultMetadata, + defaultTimeout, +} from './constant'; +import { alipayConfigGuard, AlipayConfig, AccessTokenResponse, UserInfoResponse } from './types'; +import { signingPamameters } from './utils'; +import type { SigningPamameters } from './utils'; + +export type { AlipayConfig } from './types'; + +export class AlipayConnector implements SocialConnector { + public metadata: ConnectorMetadata = defaultMetadata; + + public getConfig: GetConnectorConfig; + + private readonly signingPamameters: SigningPamameters = signingPamameters; + + constructor(getConnectorConfig: GetConnectorConfig) { + this.getConfig = getConnectorConfig; + } + + public validateConfig: ValidateConfig = async (config: unknown) => { + const result = alipayConfigGuard.safeParse(config); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); + } + }; + + public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => { + const { appId: app_id } = await this.getConfig(this.metadata.id); + + const redirect_uri = encodeURI(redirectUri); + + return `${authorizationEndpoint}?${stringify({ + app_id, + redirect_uri, // The variable `redirectUri` should match {appId, appSecret} + scope, + state, + })}`; + }; + + public getAccessToken: GetAccessToken = async (code): Promise => { + const config = await this.getConfig(this.metadata.id); + const initSearchParameters = { + method: methodForAccessToken, + format: 'JSON', + timestamp: this.getTimestamp(), + version: '1.0', + grant_type: 'authorization_code', + code, + charset: 'UTF8', + ...config, + }; + const signedSearchParameters = this.signingPamameters(initSearchParameters); + + const response = await got + .post(alipayEndpoint, { + searchParams: signedSearchParameters, + timeout: defaultTimeout, + }) + .json(); + + const { msg, sub_msg } = response.error_response ?? {}; + assert( + response.alipay_system_oauth_token_response, + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg) + ); + const { access_token: accessToken } = response.alipay_system_oauth_token_response; + + assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); + + return { accessToken }; + }; + + public getUserInfo: GetUserInfo = async (accessTokenObject) => { + const config = await this.getConfig(this.metadata.id); + const { accessToken } = accessTokenObject; + assert( + accessToken && config, + new ConnectorError(ConnectorErrorCodes.InsufficientRequestParameters) + ); + + const initSearchParameters = { + method: methodForUserInfo, + format: 'JSON', + timestamp: this.getTimestamp(), + version: '1.0', + grant_type: 'authorization_code', + auth_token: accessToken, + biz_content: JSON.stringify({}), + charset: 'UTF8', + ...config, + }; + const signedSearchParameters = this.signingPamameters(initSearchParameters); + + const response = await got + .post(alipayEndpoint, { + searchParams: signedSearchParameters, + timeout: defaultTimeout, + }) + .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) + + assert(id, new ConnectorError(ConnectorErrorCodes.InvalidResponse)); + + return { id, avatar, name }; + }; + + private readonly getTimestamp = (): string => dayjs().format('YYYY-MM-DD HH:mm:ss'); +} diff --git a/packages/connector-alipay/src/mock.ts b/packages/connector-alipay/src/mock.ts new file mode 100644 index 000000000..c6fa2b39d --- /dev/null +++ b/packages/connector-alipay/src/mock.ts @@ -0,0 +1,25 @@ +import { AlipayConfig } from './types'; + +export const mockedTimestamp = '2022-02-22 22:22:22'; + +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: mockedTimestamp, + version: '1.0', + charset: 'UTF8', + method: '', +}; diff --git a/packages/connector-alipay/src/types.ts b/packages/connector-alipay/src/types.ts new file mode 100644 index 000000000..8cc550153 --- /dev/null +++ b/packages/connector-alipay/src/types.ts @@ -0,0 +1,45 @@ +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/connector-alipay/src/utils.test.ts b/packages/connector-alipay/src/utils.test.ts new file mode 100644 index 000000000..5e949a9ab --- /dev/null +++ b/packages/connector-alipay/src/utils.test.ts @@ -0,0 +1,57 @@ +import { methodForAccessToken } from './constant'; +import { mockedAlipayConfigWithValidPrivateKey, mockedAlipayPublicParameters } from './mock'; +import { signingPamameters } from './utils'; + +const listenJSONParse = jest.spyOn(JSON, 'parse'); +const listenJSONStringify = jest.spyOn(JSON, 'stringify'); + +describe('signingParameters', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const testingParameters = { + ...mockedAlipayPublicParameters, + ...mockedAlipayConfigWithValidPrivateKey, + method: methodForAccessToken, + code: '7ffeb112fbb6495c9e7dfb720380DD39', + }; + + it('should return exact signature with the given parameters (functionality check)', () => { + const decamelizedParameters = 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 = 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', () => { + signingPamameters(testingParameters); + expect(listenJSONParse).not.toHaveBeenCalled(); + }); + + it('should call JSON.parse() when biz_content is not empty', () => { + signingPamameters({ + ...testingParameters, + biz_content: JSON.stringify({ AB: 'AB' }), + }); + expect(listenJSONParse).toHaveBeenCalled(); + }); + + it('should call JSON.stringify() when some value is object string', () => { + signingPamameters({ + ...testingParameters, + testObject: JSON.stringify({ AB: 'AB' }), + }); + expect(listenJSONStringify).toHaveBeenCalled(); + }); +}); diff --git a/packages/connector-alipay/src/utils.ts b/packages/connector-alipay/src/utils.ts new file mode 100644 index 000000000..f46b778cc --- /dev/null +++ b/packages/connector-alipay/src/utils.ts @@ -0,0 +1,51 @@ +import * as crypto from 'crypto'; + +import * as iconv from 'iconv-lite'; +import snakeCaseKeys from 'snakecase-keys'; + +import { alipaySigningAlgorithmMapping } from './constant'; +import { AlipayConfig } from './types'; + +export type SigningPamameters = ( + parameters: AlipayConfig & Record +) => Record; + +// Reference: https://github.com/alipay/alipay-sdk-nodejs-all/blob/10d78e0adc7f310d5b07567ce7e4c13a3f6c768f/lib/util.ts +export const signingPamameters: 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 }; +}; diff --git a/packages/connector-alipay/tsconfig.base.json b/packages/connector-alipay/tsconfig.base.json new file mode 100644 index 000000000..848a915f7 --- /dev/null +++ b/packages/connector-alipay/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-alipay/tsconfig.build.json b/packages/connector-alipay/tsconfig.build.json new file mode 100644 index 000000000..d42923dd3 --- /dev/null +++ b/packages/connector-alipay/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-alipay/tsconfig.json b/packages/connector-alipay/tsconfig.json new file mode 100644 index 000000000..20354364a --- /dev/null +++ b/packages/connector-alipay/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "types": ["node", "jest", "jest-matcher-specific-error"] + }, + "include": ["src", "jest.config.ts"] +} diff --git a/packages/connector-alipay/tsconfig.test.json b/packages/connector-alipay/tsconfig.test.json new file mode 100644 index 000000000..98c16f367 --- /dev/null +++ b/packages/connector-alipay/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/packages/connector-types/src/index.ts b/packages/connector-types/src/index.ts index 10d30f0b7..b5ce77494 100644 --- a/packages/connector-types/src/index.ts +++ b/packages/connector-types/src/index.ts @@ -84,8 +84,6 @@ export interface SocialConnector extends BaseConnector { getAuthorizationUri: GetAuthorizationUri; getAccessToken: GetAccessToken; getUserInfo: GetUserInfo; - getRequestTimeout?: GetTimeout; - getTimestamp?: GetTimestamp; } export type ValidateConfig> = (config: T) => Promise; @@ -101,7 +99,3 @@ export type GetUserInfo = ( ) => Promise<{ id: string } & Record>; export type GetConnectorConfig> = (id: string) => Promise; - -export type GetTimeout = () => Promise; - -export type GetTimestamp = () => string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce3c1c868..913fd7e46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,69 @@ importers: ts-node: 10.4.0_e6a8a9b497f380f485f6d23f5cd591ca typescript: 4.6.3 + packages/connector-alipay: + specifiers: + '@jest/types': ^27.5.1 + '@logto/connector-types': ^0.1.0 + '@logto/jest-config': ^0.1.0 + '@logto/shared': ^0.1.0 + '@shopify/jest-koa-mocks': ^3.0.8 + '@silverhand/eslint-config': ^0.10.2 + '@silverhand/essentials': ^1.1.0 + '@silverhand/ts-config': ^0.10.2 + '@types/jest': ^27.4.1 + '@types/lodash.pick': ^4.4.6 + '@types/node': ^16.3.1 + '@types/supertest': ^2.0.11 + copyfiles: ^2.4.1 + dayjs: ^1.10.5 + eslint: ^8.10.0 + got: ^11.8.2 + iconv-lite: 0.6.3 + jest: ^27.5.1 + jest-matcher-specific-error: ^1.0.0 + lint-staged: ^11.1.1 + nock: ^13.2.2 + prettier: ^2.3.2 + query-string: ^7.0.1 + snakecase-keys: ^5.1.0 + supertest: ^6.2.2 + ts-jest: ^27.1.1 + tsc-watch: ^4.4.0 + typescript: ^4.6.2 + zod: ^3.14.3 + dependencies: + '@logto/connector-types': link:../connector-types + '@logto/jest-config': link:../jest-config + '@logto/shared': link:../shared + '@silverhand/essentials': 1.1.7 + dayjs: 1.10.7 + got: 11.8.3 + iconv-lite: 0.6.3 + query-string: 7.0.1 + snakecase-keys: 5.1.2 + zod: 3.14.3 + devDependencies: + '@jest/types': 27.5.1 + '@shopify/jest-koa-mocks': 3.0.8 + '@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709 + '@silverhand/ts-config': 0.10.2_typescript@4.6.3 + '@types/jest': 27.4.1 + '@types/lodash.pick': 4.4.6 + '@types/node': 16.11.12 + '@types/supertest': 2.0.11 + copyfiles: 2.4.1 + eslint: 8.10.0 + jest: 27.5.1 + jest-matcher-specific-error: 1.0.0 + lint-staged: 11.2.6 + nock: 13.2.2 + prettier: 2.5.1 + supertest: 6.2.2 + ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea + tsc-watch: 4.5.0_typescript@4.6.3 + typescript: 4.6.3 + packages/connector-types: specifiers: '@jest/types': ^27.5.1 @@ -5362,7 +5425,6 @@ packages: dependencies: lodash.orderby: 4.6.0 lodash.pick: 4.4.0 - dev: true /@silverhand/ts-config-react/0.10.3_typescript@4.6.2: resolution: {integrity: sha512-xGOwcw1HTixfP3PSSdJT3leGnlUV0dLna9xp58bDDLul7UCnIn+PNp1VNJxUZ/HvtKbV4ZSYdGsGE6Xqmwn7Ag==} @@ -19010,6 +19072,21 @@ packages: yn: 3.1.1 dev: true + /tsc-watch/4.5.0_typescript@4.6.3: + resolution: {integrity: sha512-aXhN4jY+1YEcn/NwCQ/+fHqU43EqOpW+pS+933EPsVEsrKhvyrodPDIjQsk1a1niFrabAK3RIBrRbAslVefEbQ==} + engines: {node: '>=8.17.0'} + hasBin: true + peerDependencies: + typescript: '*' + dependencies: + cross-spawn: 7.0.3 + node-cleanup: 2.1.2 + ps-tree: 1.2.0 + string-argv: 0.1.2 + strip-ansi: 6.0.1 + typescript: 4.6.3 + dev: true + /tsc-watch/5.0.3_typescript@4.6.2: resolution: {integrity: sha512-Hz2UawwELMSLOf0xHvAFc7anLeMw62cMVXr1flYmhRuOhOyOljwmb1l/O60ZwRyy1k7N1iC1mrn1QYM2zITfuw==} engines: {node: '>=8.17.0'}