0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(core): fix connectors' initialization

This commit is contained in:
Darcy Ye 2022-04-29 10:37:13 +08:00
parent 0a1c74ddb1
commit c6f2546126
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
52 changed files with 59 additions and 2553 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof getConnectorConfig>).mockResolvedValue(
mockedAlipayConfig
);
(getFormattedDate as jest.MockedFunction<typeof getFormattedDate>).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: '<signature>',
});
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: '<signature>',
});
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: '<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);
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: '<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 () => {
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: '<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 () => {
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: '<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 () => {
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: '<signature>',
});
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();
});
});

View file

@ -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<string, string>
): 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 };
};
export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const { appId: app_id } = await getConnectorConfig<AlipayConfig>(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<AlipayConfig>(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<AccessTokenResponse>();
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<AlipayConfig>(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<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)
assertThat(id, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
return { id, avatar, name };
};

View file

@ -1,23 +0,0 @@
import { AlipayConfig } from './types';
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: '2022-02-22 22:22:22',
version: '1.0',
charset: 'UTF8',
method: '<method-placeholder>',
};

View file

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

@ -1,2 +0,0 @@
### Aliyun DM README
placeholder

View file

@ -1,23 +0,0 @@
{
"accessKeyId": "<access-key-id>",
"accessKeySecret": "<access-key-secret>",
"accountName": "<verified-account-name>",
"fromAlias": "<connector-alias>",
"templates": [
{
"usageType": "SIGN_IN",
"subject": "<sign-in-template-subject>",
"content": "<sign-in-template-content>"
},
{
"usageType": "REGISTER",
"subject": "<register-template-subject>",
"content": "<register-template-content>"
},
{
"usageType": "TEST",
"subject": "<test-template-subject>",
"content": "<test-template-content>"
}
]
}

View file

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

View file

@ -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<typeof configGuard>;
export const sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await getConnectorConfig<AliyunDmConfig>(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
);
};

View file

@ -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: '<access-key-id>',
AccountName: 'noreply@example.com',
AddressType: '1',
FromAlias: 'CompanyName',
HtmlBody: 'test from logto',
ReplyToAddress: 'false',
Subject: 'test',
ToAddress: 'user@example.com',
},
'<access-key-secret>'
);
const calledData = (request as jest.MockedFunction<typeof request>).mock.calls[0];
expect(calledData).not.toBeUndefined();
const payload = calledData?.[1];
expect(payload).toHaveProperty('Action', 'SingleSendMail');
});
});

View file

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

View file

@ -1,2 +0,0 @@
### Aliyun SMS README
placeholder

View file

@ -1,31 +0,0 @@
{
"accessKeyId": "<access-key-id>",
"accessKeySecret": "<access-key-secret>",
"signName": "<sign-name>",
"templates": [
{
"type": 0,
"usageType": "SIGN_IN",
"code": "<temporary-passcode>",
"name": "<sign-in-template-name>",
"content": "<sign-in-template-content>",
"remark": "<sign-in-template-remark>"
},
{
"type": 0,
"usageType": "REGISTER",
"code": "<temporary-passcode>",
"name": "<register-template-name>",
"content": "<register-template-content>",
"remark": "<register-template-remark>"
},
{
"type": 0,
"usageType": "TEST",
"code": "<temporary-passcode>",
"name": "<test-template-name>",
"content": "<test-template-content>",
"remark": "<test-template-remark>"
},
]
}

View file

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

View file

@ -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<typeof configGuard>;
export const sendMessage: SmsSendMessageFunction = async (phone, type, { code }) => {
const config = await getConnectorConfig<AliyunSmsConfig>(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
);
};

View file

@ -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: '<access-key-id>',
PhoneNumbers: '13912345678',
SignName: '阿里云短信测试',
TemplateCode: ' SMS_154950909',
TemplateParam: JSON.stringify({ code }),
},
'<access-key-secret>'
);
const calledData = (request as jest.MockedFunction<typeof request>).mock.calls[0];
expect(calledData).not.toBeUndefined();
const payload = calledData?.[1];
expect(payload).toHaveProperty('Action', 'SendSms');
});
});

View file

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

View file

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

View file

@ -1,4 +0,0 @@
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}

View file

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

View file

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

View file

@ -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<typeof facebookConfigGuard>;
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<FacebookConfig>(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<FacebookConfig>(metadata.id);
const { access_token: accessToken } = await got
.get(accessTokenEndpoint, {
searchParams: {
code,
client_id,
client_secret,
redirect_uri: redirectUri,
},
timeout: await getConnectorRequestTimeout(),
})
.json<AccessTokenResponse>();
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<UserInfoResponse>();
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;
}
};

View file

@ -1,28 +0,0 @@
# GitHub Connector
The GitHub connector provides the ability to easily integrate GitHubs 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 websites 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

View file

@ -1,4 +0,0 @@
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}

View file

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

View file

@ -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<typeof getConnectorConfig>).mockResolvedValue({
clientId: '<client-id>',
clientSecret: '<client-secret>',
});
});
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();
});
});

View file

@ -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<typeof githubConfigGuard>;
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<GithubConfig>(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<GithubConfig>(metadata.id);
const { access_token: accessToken } = await got
.post({
url: accessTokenEndpoint,
json: {
client_id,
client_secret,
code,
},
timeout: await getConnectorRequestTimeout(),
})
.json<AccessTokenResponse>();
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<UserInfoResponse>();
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;
}
};

View file

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

View file

@ -1,4 +0,0 @@
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}

View file

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

View file

@ -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<typeof getConnectorConfig>).mockResolvedValue({
clientId: '<client-id>',
clientSecret: '<client-secret>',
});
});
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();
});
});
});

View file

@ -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<typeof googleConfigGuard>;
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<GoogleConfig>(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<GoogleConfig>(metadata.id);
// NoteNeed 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<AccessTokenResponse>();
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<UserInfoResponse>();
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;
}
};

View file

@ -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<ConnectorInstance[]> => {

View file

@ -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<string, unknown>;
};
type SmsMessageTypes = EmailMessageTypes;
export type EmailSendMessageFunction<T = unknown> = (
address: string,
type: keyof EmailMessageTypes,
payload: EmailMessageTypes[typeof type]
) => Promise<T>;
export type SmsSendMessageFunction<T = unknown> = (
phone: string,
type: keyof SmsMessageTypes,
payload: SmsMessageTypes[typeof type]
) => Promise<T>;
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<T extends ArbitraryObject = ArbitraryObject> = (
config: T
) => Promise<void>;
export type GetAuthorizationUri = (redirectUri: string, state: string) => Promise<string>;
type AccessTokenObject = { accessToken: string } & Record<string, string>;
export type GetAccessToken = (code: string, redirectUri?: string) => Promise<AccessTokenObject>;
export type GetUserInfo = (accessTokenObject: AccessTokenObject) => Promise<SocialUserInfo>;
export const socialUserInfoGuard = z.object({
id: z.string(),
email: z.string().optional(),

View file

@ -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: "<a%b'>",
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: "<a%b'>",
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<typeof got.post>).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();
});
});

View file

@ -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<string, string>,
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 <T>(
url: string,
parameters: PublicParameters & Record<string, string>,
accessKeySecret: string
) => {
const finalParameters: Record<string, string> = {
...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<T>({
url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
form: payload,
});
};

View file

@ -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 <T extends ArbitraryObject>(
set: { config },
});
};
const connectorRequestTimeout = 5000;
export const getConnectorRequestTimeout = async (): Promise<number> => connectorRequestTimeout;
export const getFormattedDate = (): string => {
return dayjs().format('YYYY-MM-DD HH:mm:ss');
};

View file

@ -1,2 +0,0 @@
export const authorizationEndpoint = 'https://wechat.native/';
export const scope = 'snsapi_userinfo';

View file

@ -1,24 +0,0 @@
import { getAuthorizationUri } from '.';
import { getConnectorConfig } from '../utilities';
import { authorizationEndpoint } from './constant';
jest.mock('../utilities');
beforeAll(() => {
(getConnectorConfig as jest.MockedFunction<typeof getConnectorConfig>).mockResolvedValue({
appId: '<app-id>',
appSecret: '<app-secret>',
});
});
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`
);
});
});

View file

@ -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<WeChatConfig>(metadata.id);
return `${authorizationEndpoint}?${stringify({
appid: appId,
redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret}
scope,
state,
})}`;
};

View file

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

View file

@ -1,4 +0,0 @@
{
"appId": "<app-id>",
"appSecret": "<app-secret>"
}

View file

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

View file

@ -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<typeof getConnectorConfig>).mockResolvedValue({
appId: '<app-id>',
appSecret: '<app-secret>',
});
});
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: '<app-id>',
secret: '<app-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)
);
});
});

View file

@ -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<typeof weChatConfigGuard>;
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<WeChatConfig>(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<WeChatConfig>(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<AccessTokenResponse>();
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<UserInfoResponse>();
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;
}
};

View file

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

View file

@ -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<StateT, ContextT>(): Middleware<StateT, ContextT> {

View file

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

18
pnpm-lock.yaml generated
View file

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