0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): wrap alipay web connector (#643)

* feat(core): wrap Alipay web connector and fix UTs/dependencies accordingly

* feat(core): changed some utils functions' modifier from public to private

* feat(core): fix node version

* feat(core): removed unnecessary dependency
This commit is contained in:
Darcy Ye 2022-04-26 09:57:32 +08:00 committed by GitHub
parent 5e7744095a
commit 68a64d3ef5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 793 additions and 7 deletions

View file

@ -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;

View file

@ -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"
}

View file

@ -0,0 +1,2 @@
### Alipay Web Social Connector README
placeholder

View file

@ -0,0 +1,5 @@
{
"appId": "<app-id>",
"signType": "<signing-algorithm>",
"privateKey": "<private-key>"
}

View file

@ -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;

View file

@ -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<AlipayConfig>;
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: '<signature>',
});
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: '<signature>',
});
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: '<signature>',
});
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: '<signature>',
});
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: '<signature>',
});
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: '<signature>',
});
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: '<signature>',
});
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();
});
});

View file

@ -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<AlipayConfig>;
private readonly signingPamameters: SigningPamameters = signingPamameters;
constructor(getConnectorConfig: GetConnectorConfig<AlipayConfig>) {
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<AccessTokenObject> => {
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<AccessTokenResponse>();
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<UserInfoResponse>();
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');
}

View file

@ -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: '<private-key>',
};
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: '<method-placeholder>',
};

View file

@ -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<typeof alipayConfigGuard>;
// `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;
};
};

View file

@ -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();
});
});

View file

@ -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<string, string | undefined>
) => Record<string, string>;
// Reference: https://github.com/alipay/alipay-sdk-nodejs-all/blob/10d78e0adc7f310d5b07567ce7e4c13a3f6c768f/lib/util.ts
export const signingPamameters: SigningPamameters = (
parameters: AlipayConfig & Record<string, string | undefined>
): Record<string, string> => {
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 };
};

View file

@ -0,0 +1,10 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.base",
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base",
"compilerOptions": {
"types": ["node", "jest", "jest-matcher-specific-error"]
},
"include": ["src", "jest.config.ts"]
}

View file

@ -0,0 +1,6 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false
}
}

View file

@ -84,8 +84,6 @@ export interface SocialConnector extends BaseConnector {
getAuthorizationUri: GetAuthorizationUri;
getAccessToken: GetAccessToken;
getUserInfo: GetUserInfo;
getRequestTimeout?: GetTimeout;
getTimestamp?: GetTimestamp;
}
export type ValidateConfig<T = Record<string, unknown>> = (config: T) => Promise<void>;
@ -101,7 +99,3 @@ export type GetUserInfo = (
) => Promise<{ id: string } & Record<string, string | undefined>>;
export type GetConnectorConfig<T = Record<string, unknown>> = (id: string) => Promise<T>;
export type GetTimeout = () => Promise<number>;
export type GetTimestamp = () => string;

View file

@ -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'}