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:
parent
0a1c74ddb1
commit
c6f2546126
52 changed files with 59 additions and 2553 deletions
|
@ -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",
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
### Alipay Web Social Connector README
|
||||
placeholder
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"appId": "<app-id>",
|
||||
"signType": "<signing-algorithm>",
|
||||
"privateKey": "<private-key>"
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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>',
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
### Aliyun DM README
|
||||
placeholder
|
|
@ -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>"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
### Aliyun SMS README
|
||||
placeholder
|
|
@ -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>"
|
||||
},
|
||||
]
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
### FB Social Connector README
|
||||
placeholder
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"clientId": "<client-id>",
|
||||
"clientSecret": "<client-secret>"
|
||||
}
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -1,28 +0,0 @@
|
|||
# GitHub Connector
|
||||
|
||||
The GitHub connector provides the ability to easily integrate GitHub’s OAuth App.
|
||||
|
||||
Official guide on OAuth App: [https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A GitHub account, both personal and organization are OK.
|
||||
|
||||
## Configuration On GitHub
|
||||
|
||||
Follow the official guide above to create an OAuth App step by step, notice on those fields:
|
||||
|
||||
### 1. Homepage URL
|
||||
|
||||
Simply input your website’s url like `www.example.com`
|
||||
|
||||
### 2. Authorization callback URL
|
||||
|
||||
also `www.example.com`
|
||||
|
||||
## Settings
|
||||
|
||||
Name | Type | Description | Required
|
||||
------------ | ------------ | ------------- | ---
|
||||
clientId | string | The client ID you received from GitHub when you created an OAuth App | YES
|
||||
clientSecret | string | The client secret you received from GitHub when you created an OAuth App | YES
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"clientId": "<client-id>",
|
||||
"clientSecret": "<client-secret>"
|
||||
}
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
### Google Social Connector README
|
||||
placeholder
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"clientId": "<client-id>",
|
||||
"clientSecret": "<client-secret>"
|
||||
}
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
||||
// Note:Need to decodeURIComponent on code
|
||||
// https://stackoverflow.com/questions/51058256/google-api-node-js-invalid-grant-malformed-auth-code
|
||||
const { access_token: accessToken } = await got
|
||||
.post(accessTokenEndpoint, {
|
||||
form: {
|
||||
code: decodeURIComponent(code),
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
},
|
||||
timeout: await getConnectorRequestTimeout(),
|
||||
followRedirect: true,
|
||||
})
|
||||
.json<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;
|
||||
}
|
||||
};
|
|
@ -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[]> => {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
export const authorizationEndpoint = 'https://wechat.native/';
|
||||
export const scope = 'snsapi_userinfo';
|
|
@ -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`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
})}`;
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
### WeChat Social Connector README
|
||||
placeholder
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"appId": "<app-id>",
|
||||
"appSecret": "<app-secret>"
|
||||
}
|
|
@ -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';
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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',
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
18
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue