mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): wrap wechat connector (#676)
* feat(core): wrap wechat web connector * feat(core): wrap wechat native connector * feat(core): fix dependency and remove query-string.stringify()
This commit is contained in:
parent
82f7d488bd
commit
e56a4894b7
25 changed files with 959 additions and 0 deletions
2
packages/connector-wechat-native/README.md
Normal file
2
packages/connector-wechat-native/README.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
### WeChat Native Social Connector README
|
||||
placeholder
|
6
packages/connector-wechat-native/docs/config-template.md
Normal file
6
packages/connector-wechat-native/docs/config-template.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
```json
|
||||
{
|
||||
"appId": "<app-id>",
|
||||
"appSecret": "<app-secret>"
|
||||
}
|
||||
```
|
8
packages/connector-wechat-native/jest.config.ts
Normal file
8
packages/connector-wechat-native/jest.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Config, merge } from '@logto/jest-config';
|
||||
|
||||
const config: Config.InitialOptions = merge({
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['jest-matcher-specific-error'],
|
||||
});
|
||||
|
||||
export default config;
|
57
packages/connector-wechat-native/package.json
Normal file
57
packages/connector-wechat-native/package.json
Normal file
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "@logto/connector-wechat-native",
|
||||
"version": "0.1.0",
|
||||
"description": "WeChat native connector implementation.",
|
||||
"main": "./lib/index.js",
|
||||
"exports": "./lib/index.js",
|
||||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"license": "MPL-2.0",
|
||||
"files": [
|
||||
"lib",
|
||||
"docs"
|
||||
],
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"precommit": "lint-staged",
|
||||
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"lint:report": "pnpm lint -- --format json --output-file report.json",
|
||||
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
|
||||
"test": "jest",
|
||||
"test:coverage": "jest --coverage --silent",
|
||||
"prepack": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/connector-types": "^0.1.0",
|
||||
"@logto/jest-config": "^0.1.0",
|
||||
"@logto/shared": "^0.1.0",
|
||||
"@logto/schemas": "^0.1.0",
|
||||
"@silverhand/essentials": "^1.1.0",
|
||||
"got": "^11.8.2",
|
||||
"zod": "^3.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/types": "^27.5.1",
|
||||
"@silverhand/eslint-config": "^0.10.2",
|
||||
"@silverhand/ts-config": "^0.10.2",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^16.3.1",
|
||||
"eslint": "^8.10.0",
|
||||
"jest": "^27.5.1",
|
||||
"jest-matcher-specific-error": "^1.0.0",
|
||||
"lint-staged": "^11.1.1",
|
||||
"nock": "^13.2.2",
|
||||
"prettier": "^2.3.2",
|
||||
"ts-jest": "^27.1.1",
|
||||
"tsc-watch": "^4.4.0",
|
||||
"typescript": "^4.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand"
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
}
|
39
packages/connector-wechat-native/src/constant.ts
Normal file
39
packages/connector-wechat-native/src/constant.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import path from 'path';
|
||||
|
||||
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
|
||||
import { getFileContents } from '@logto/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const authorizationEndpoint = 'https://wechat.native/'; // This is used to arouse the native WeChat App
|
||||
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_userinfo';
|
||||
|
||||
export const weChatNativeConfigGuard = z.object({ appId: z.string(), appSecret: z.string() });
|
||||
|
||||
export type WeChatNativeConfig = z.infer<typeof weChatNativeConfigGuard>;
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const currentPath = __dirname;
|
||||
const pathToReadmeFile = path.join(currentPath, '..', 'README.md');
|
||||
const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md');
|
||||
const readmeContentFallback = 'Please check README.md file directory.';
|
||||
const configTemplateFallback = 'Please check config-template.md file directory.';
|
||||
|
||||
export const defaultMetadata: ConnectorMetadata = {
|
||||
id: 'wechat-native',
|
||||
type: ConnectorType.Social,
|
||||
name: {
|
||||
en: 'Sign In with WeChat',
|
||||
'zh-CN': '微信登录',
|
||||
},
|
||||
logo: './logo.png',
|
||||
description: {
|
||||
en: 'Sign In with WeChat',
|
||||
'zh-CN': '微信登录',
|
||||
},
|
||||
readme: getFileContents(pathToReadmeFile, readmeContentFallback),
|
||||
configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback),
|
||||
};
|
||||
|
||||
export const defaultTimeout = 5000;
|
151
packages/connector-wechat-native/src/index.test.ts
Normal file
151
packages/connector-wechat-native/src/index.test.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { ConnectorError, ConnectorErrorCodes, GetConnectorConfig } from '@logto/connector-types';
|
||||
import nock from 'nock';
|
||||
|
||||
import { WeChatNativeConnector } from '.';
|
||||
import {
|
||||
WeChatNativeConfig,
|
||||
accessTokenEndpoint,
|
||||
authorizationEndpoint,
|
||||
userInfoEndpoint,
|
||||
} from './constant';
|
||||
import { mockedConfig } from './mock';
|
||||
|
||||
const getConnectorConfig = jest.fn() as GetConnectorConfig<WeChatNativeConfig>;
|
||||
|
||||
const weChatNativeMethods = new WeChatNativeConnector(getConnectorConfig);
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(weChatNativeMethods, 'getConfig').mockResolvedValue(mockedConfig);
|
||||
});
|
||||
|
||||
describe('getAuthorizationUri', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get a valid uri by redirectUri and state', async () => {
|
||||
const authorizationUri = await weChatNativeMethods.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`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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 weChatNativeMethods.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(weChatNativeMethods.getAccessToken('code')).rejects.toMatchError(
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should pass on valid config', async () => {
|
||||
await expect(
|
||||
weChatNativeMethods.validateConfig({ appId: 'appId', appSecret: 'appSecret' })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
it('should throw on empty config', async () => {
|
||||
await expect(weChatNativeMethods.validateConfig({})).rejects.toThrowError();
|
||||
});
|
||||
it('should throw when missing appSecret', async () => {
|
||||
await expect(weChatNativeMethods.validateConfig({ appId: 'appId' })).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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 weChatNativeMethods.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(
|
||||
weChatNativeMethods.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(
|
||||
weChatNativeMethods.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(
|
||||
weChatNativeMethods.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(
|
||||
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken' })
|
||||
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
|
||||
});
|
||||
});
|
137
packages/connector-wechat-native/src/index.ts
Normal file
137
packages/connector-wechat-native/src/index.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* 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 {
|
||||
ConnectorMetadata,
|
||||
GetAccessToken,
|
||||
GetAuthorizationUri,
|
||||
ValidateConfig,
|
||||
GetUserInfo,
|
||||
ConnectorError,
|
||||
ConnectorErrorCodes,
|
||||
SocialConnector,
|
||||
GetConnectorConfig,
|
||||
} from '@logto/connector-types';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import got, { RequestError as GotRequestError } from 'got';
|
||||
|
||||
import {
|
||||
authorizationEndpoint,
|
||||
accessTokenEndpoint,
|
||||
userInfoEndpoint,
|
||||
scope,
|
||||
defaultMetadata,
|
||||
defaultTimeout,
|
||||
weChatNativeConfigGuard,
|
||||
WeChatNativeConfig,
|
||||
} from './constant';
|
||||
|
||||
// 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
|
||||
|
||||
export class WeChatNativeConnector implements SocialConnector {
|
||||
public metadata: ConnectorMetadata = defaultMetadata;
|
||||
|
||||
public getConfig: GetConnectorConfig<WeChatNativeConfig>;
|
||||
|
||||
constructor(getConnectorConfig: GetConnectorConfig<WeChatNativeConfig>) {
|
||||
this.getConfig = getConnectorConfig;
|
||||
}
|
||||
|
||||
public validateConfig: ValidateConfig = async (config: unknown) => {
|
||||
const result = weChatNativeConfigGuard.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
|
||||
}
|
||||
};
|
||||
|
||||
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
|
||||
const { appId } = await this.getConfig(this.metadata.id);
|
||||
|
||||
const queryParameters = new URLSearchParams({
|
||||
appid: appId,
|
||||
redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret}
|
||||
scope,
|
||||
state,
|
||||
});
|
||||
|
||||
return `${authorizationEndpoint}?${queryParameters.toString()}`;
|
||||
};
|
||||
|
||||
public getAccessToken: GetAccessToken = async (code) => {
|
||||
type AccessTokenResponse = {
|
||||
access_token?: string;
|
||||
openid?: string;
|
||||
expires_in?: number; // In seconds
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
errcode?: number;
|
||||
};
|
||||
|
||||
const { appId: appid, appSecret: secret } = await this.getConfig(this.metadata.id);
|
||||
|
||||
const {
|
||||
access_token: accessToken,
|
||||
openid,
|
||||
errcode,
|
||||
} = await got
|
||||
.get(accessTokenEndpoint, {
|
||||
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
|
||||
timeout: defaultTimeout,
|
||||
})
|
||||
.json<AccessTokenResponse>();
|
||||
|
||||
assert(
|
||||
errcode !== 40_029 && accessToken && openid,
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||
);
|
||||
|
||||
return { accessToken, openid };
|
||||
};
|
||||
|
||||
public 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: defaultTimeout,
|
||||
})
|
||||
.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;
|
||||
}
|
||||
};
|
||||
}
|
4
packages/connector-wechat-native/src/mock.ts
Normal file
4
packages/connector-wechat-native/src/mock.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export const mockedConfig = {
|
||||
appId: '<app-id>',
|
||||
appSecret: '<app-secret>',
|
||||
};
|
10
packages/connector-wechat-native/tsconfig.base.json
Normal file
10
packages/connector-wechat-native/tsconfig.base.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
5
packages/connector-wechat-native/tsconfig.build.json
Normal file
5
packages/connector-wechat-native/tsconfig.build.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.base",
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
7
packages/connector-wechat-native/tsconfig.json
Normal file
7
packages/connector-wechat-native/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest", "jest-matcher-specific-error"]
|
||||
},
|
||||
"include": ["src", "jest.config.ts"]
|
||||
}
|
6
packages/connector-wechat-native/tsconfig.test.json
Normal file
6
packages/connector-wechat-native/tsconfig.test.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false
|
||||
}
|
||||
}
|
2
packages/connector-wechat/README.md
Normal file
2
packages/connector-wechat/README.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
### WeChat Web Social Connector README
|
||||
placeholder
|
6
packages/connector-wechat/docs/config-template.md
Normal file
6
packages/connector-wechat/docs/config-template.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
```json
|
||||
{
|
||||
"appId": "<app-id>",
|
||||
"appSecret": "<app-secret>"
|
||||
}
|
||||
```
|
8
packages/connector-wechat/jest.config.ts
Normal file
8
packages/connector-wechat/jest.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Config, merge } from '@logto/jest-config';
|
||||
|
||||
const config: Config.InitialOptions = merge({
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['jest-matcher-specific-error'],
|
||||
});
|
||||
|
||||
export default config;
|
57
packages/connector-wechat/package.json
Normal file
57
packages/connector-wechat/package.json
Normal file
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "@logto/connector-wechat",
|
||||
"version": "0.1.0",
|
||||
"description": "Wechat Web connector implementation.",
|
||||
"main": "./lib/index.js",
|
||||
"exports": "./lib/index.js",
|
||||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"license": "MPL-2.0",
|
||||
"files": [
|
||||
"lib",
|
||||
"docs"
|
||||
],
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"precommit": "lint-staged",
|
||||
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"lint:report": "pnpm lint -- --format json --output-file report.json",
|
||||
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
|
||||
"test": "jest",
|
||||
"test:coverage": "jest --coverage --silent",
|
||||
"prepack": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/connector-types": "^0.1.0",
|
||||
"@logto/jest-config": "^0.1.0",
|
||||
"@logto/shared": "^0.1.0",
|
||||
"@logto/schemas": "^0.1.0",
|
||||
"@silverhand/essentials": "^1.1.0",
|
||||
"got": "^11.8.2",
|
||||
"zod": "^3.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/types": "^27.5.1",
|
||||
"@silverhand/eslint-config": "^0.10.2",
|
||||
"@silverhand/ts-config": "^0.10.2",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^16.3.1",
|
||||
"eslint": "^8.10.0",
|
||||
"jest": "^27.5.1",
|
||||
"jest-matcher-specific-error": "^1.0.0",
|
||||
"lint-staged": "^11.1.1",
|
||||
"nock": "^13.2.2",
|
||||
"prettier": "^2.3.2",
|
||||
"ts-jest": "^27.1.1",
|
||||
"tsc-watch": "^4.4.0",
|
||||
"typescript": "^4.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand"
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
}
|
39
packages/connector-wechat/src/constant.ts
Normal file
39
packages/connector-wechat/src/constant.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import path from 'path';
|
||||
|
||||
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
|
||||
import { getFileContents } from '@logto/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
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';
|
||||
|
||||
export const weChatConfigGuard = z.object({ appId: z.string(), appSecret: z.string() });
|
||||
|
||||
export type WeChatConfig = z.infer<typeof weChatConfigGuard>;
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const currentPath = __dirname;
|
||||
const pathToReadmeFile = path.join(currentPath, '..', 'README.md');
|
||||
const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md');
|
||||
const readmeContentFallback = 'Please check README.md file directory.';
|
||||
const configTemplateFallback = 'Please check config-template.md file directory.';
|
||||
|
||||
export const defaultMetadata: ConnectorMetadata = {
|
||||
id: 'wechat',
|
||||
type: ConnectorType.Social,
|
||||
name: {
|
||||
en: 'Sign In with WeChat',
|
||||
'zh-CN': '微信登录',
|
||||
},
|
||||
logo: './logo.png',
|
||||
description: {
|
||||
en: 'Sign In with WeChat',
|
||||
'zh-CN': '微信登录',
|
||||
},
|
||||
readme: getFileContents(pathToReadmeFile, readmeContentFallback),
|
||||
configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback),
|
||||
};
|
||||
|
||||
export const defaultTimeout = 5000;
|
151
packages/connector-wechat/src/index.test.ts
Normal file
151
packages/connector-wechat/src/index.test.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { ConnectorError, ConnectorErrorCodes, GetConnectorConfig } from '@logto/connector-types';
|
||||
import nock from 'nock';
|
||||
|
||||
import { WeChatConnector } from '.';
|
||||
import {
|
||||
WeChatConfig,
|
||||
accessTokenEndpoint,
|
||||
authorizationEndpoint,
|
||||
userInfoEndpoint,
|
||||
} from './constant';
|
||||
import { mockedConfig } from './mock';
|
||||
|
||||
const getConnectorConfig = jest.fn() as GetConnectorConfig<WeChatConfig>;
|
||||
|
||||
const weChatMethods = new WeChatConnector(getConnectorConfig);
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(weChatMethods, 'getConfig').mockResolvedValue(mockedConfig);
|
||||
});
|
||||
|
||||
describe('getAuthorizationUri', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get a valid uri by redirectUri and state', async () => {
|
||||
const authorizationUri = await weChatMethods.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();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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 weChatMethods.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(weChatMethods.getAccessToken('code')).rejects.toMatchError(
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should pass on valid config', async () => {
|
||||
await expect(
|
||||
weChatMethods.validateConfig({ appId: 'appId', appSecret: 'appSecret' })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
it('should throw on empty config', async () => {
|
||||
await expect(weChatMethods.validateConfig({})).rejects.toThrowError();
|
||||
});
|
||||
it('should throw when missing appSecret', async () => {
|
||||
await expect(weChatMethods.validateConfig({ appId: 'appId' })).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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 weChatMethods.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(
|
||||
weChatMethods.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(
|
||||
weChatMethods.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(
|
||||
weChatMethods.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(weChatMethods.getUserInfo({ accessToken: 'accessToken' })).rejects.toMatchError(
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
|
||||
);
|
||||
});
|
||||
});
|
138
packages/connector-wechat/src/index.ts
Normal file
138
packages/connector-wechat/src/index.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* 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 {
|
||||
ConnectorMetadata,
|
||||
GetAccessToken,
|
||||
GetAuthorizationUri,
|
||||
ValidateConfig,
|
||||
GetUserInfo,
|
||||
ConnectorError,
|
||||
ConnectorErrorCodes,
|
||||
SocialConnector,
|
||||
GetConnectorConfig,
|
||||
} from '@logto/connector-types';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import got, { RequestError as GotRequestError } from 'got';
|
||||
|
||||
import {
|
||||
authorizationEndpoint,
|
||||
accessTokenEndpoint,
|
||||
userInfoEndpoint,
|
||||
scope,
|
||||
defaultMetadata,
|
||||
defaultTimeout,
|
||||
weChatConfigGuard,
|
||||
WeChatConfig,
|
||||
} from './constant';
|
||||
|
||||
// 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
|
||||
|
||||
export class WeChatConnector implements SocialConnector {
|
||||
public metadata: ConnectorMetadata = defaultMetadata;
|
||||
|
||||
public getConfig: GetConnectorConfig<WeChatConfig>;
|
||||
|
||||
constructor(getConnectorConfig: GetConnectorConfig<WeChatConfig>) {
|
||||
this.getConfig = getConnectorConfig;
|
||||
}
|
||||
|
||||
public validateConfig: ValidateConfig = async (config: unknown) => {
|
||||
const result = weChatConfigGuard.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
|
||||
}
|
||||
};
|
||||
|
||||
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
|
||||
const { appId } = await this.getConfig(this.metadata.id);
|
||||
|
||||
const queryParameters = new URLSearchParams({
|
||||
appid: appId,
|
||||
redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret}
|
||||
response_type: 'code',
|
||||
scope,
|
||||
state,
|
||||
});
|
||||
|
||||
return `${authorizationEndpoint}?${queryParameters.toString()}`;
|
||||
};
|
||||
|
||||
public getAccessToken: GetAccessToken = async (code) => {
|
||||
type AccessTokenResponse = {
|
||||
access_token?: string;
|
||||
openid?: string;
|
||||
expires_in?: number; // In seconds
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
errcode?: number;
|
||||
};
|
||||
|
||||
const { appId: appid, appSecret: secret } = await this.getConfig(this.metadata.id);
|
||||
|
||||
const {
|
||||
access_token: accessToken,
|
||||
openid,
|
||||
errcode,
|
||||
} = await got
|
||||
.get(accessTokenEndpoint, {
|
||||
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
|
||||
timeout: defaultTimeout,
|
||||
})
|
||||
.json<AccessTokenResponse>();
|
||||
|
||||
assert(
|
||||
errcode !== 40_029 && accessToken && openid,
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||
);
|
||||
|
||||
return { accessToken, openid };
|
||||
};
|
||||
|
||||
public 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: defaultTimeout,
|
||||
})
|
||||
.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;
|
||||
}
|
||||
};
|
||||
}
|
4
packages/connector-wechat/src/mock.ts
Normal file
4
packages/connector-wechat/src/mock.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export const mockedConfig = {
|
||||
appId: '<app-id>',
|
||||
appSecret: '<app-secret>',
|
||||
};
|
10
packages/connector-wechat/tsconfig.base.json
Normal file
10
packages/connector-wechat/tsconfig.base.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
5
packages/connector-wechat/tsconfig.build.json
Normal file
5
packages/connector-wechat/tsconfig.build.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.base",
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
7
packages/connector-wechat/tsconfig.json
Normal file
7
packages/connector-wechat/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest", "jest-matcher-specific-error"]
|
||||
},
|
||||
"include": ["src", "jest.config.ts"]
|
||||
}
|
6
packages/connector-wechat/tsconfig.test.json
Normal file
6
packages/connector-wechat/tsconfig.test.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false
|
||||
}
|
||||
}
|
|
@ -339,6 +339,100 @@ importers:
|
|||
ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea
|
||||
typescript: 4.6.3
|
||||
|
||||
packages/connector-wechat:
|
||||
specifiers:
|
||||
'@jest/types': ^27.5.1
|
||||
'@logto/connector-types': ^0.1.0
|
||||
'@logto/jest-config': ^0.1.0
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@logto/shared': ^0.1.0
|
||||
'@silverhand/eslint-config': ^0.10.2
|
||||
'@silverhand/essentials': ^1.1.0
|
||||
'@silverhand/ts-config': ^0.10.2
|
||||
'@types/jest': ^27.4.1
|
||||
'@types/node': ^16.3.1
|
||||
eslint: ^8.10.0
|
||||
got: ^11.8.2
|
||||
jest: ^27.5.1
|
||||
jest-matcher-specific-error: ^1.0.0
|
||||
lint-staged: ^11.1.1
|
||||
nock: ^13.2.2
|
||||
prettier: ^2.3.2
|
||||
ts-jest: ^27.1.1
|
||||
tsc-watch: ^4.4.0
|
||||
typescript: ^4.6.2
|
||||
zod: ^3.14.3
|
||||
dependencies:
|
||||
'@logto/connector-types': link:../connector-types
|
||||
'@logto/jest-config': link:../jest-config
|
||||
'@logto/schemas': link:../schemas
|
||||
'@logto/shared': link:../shared
|
||||
'@silverhand/essentials': 1.1.7
|
||||
got: 11.8.3
|
||||
zod: 3.14.3
|
||||
devDependencies:
|
||||
'@jest/types': 27.5.1
|
||||
'@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709
|
||||
'@silverhand/ts-config': 0.10.2_typescript@4.6.3
|
||||
'@types/jest': 27.4.1
|
||||
'@types/node': 16.11.12
|
||||
eslint: 8.10.0
|
||||
jest: 27.5.1
|
||||
jest-matcher-specific-error: 1.0.0
|
||||
lint-staged: 11.2.6
|
||||
nock: 13.2.2
|
||||
prettier: 2.5.1
|
||||
ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea
|
||||
tsc-watch: 4.6.2_typescript@4.6.3
|
||||
typescript: 4.6.3
|
||||
|
||||
packages/connector-wechat-native:
|
||||
specifiers:
|
||||
'@jest/types': ^27.5.1
|
||||
'@logto/connector-types': ^0.1.0
|
||||
'@logto/jest-config': ^0.1.0
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@logto/shared': ^0.1.0
|
||||
'@silverhand/eslint-config': ^0.10.2
|
||||
'@silverhand/essentials': ^1.1.0
|
||||
'@silverhand/ts-config': ^0.10.2
|
||||
'@types/jest': ^27.4.1
|
||||
'@types/node': ^16.3.1
|
||||
eslint: ^8.10.0
|
||||
got: ^11.8.2
|
||||
jest: ^27.5.1
|
||||
jest-matcher-specific-error: ^1.0.0
|
||||
lint-staged: ^11.1.1
|
||||
nock: ^13.2.2
|
||||
prettier: ^2.3.2
|
||||
ts-jest: ^27.1.1
|
||||
tsc-watch: ^4.4.0
|
||||
typescript: ^4.6.2
|
||||
zod: ^3.14.3
|
||||
dependencies:
|
||||
'@logto/connector-types': link:../connector-types
|
||||
'@logto/jest-config': link:../jest-config
|
||||
'@logto/schemas': link:../schemas
|
||||
'@logto/shared': link:../shared
|
||||
'@silverhand/essentials': 1.1.7
|
||||
got: 11.8.3
|
||||
zod: 3.14.3
|
||||
devDependencies:
|
||||
'@jest/types': 27.5.1
|
||||
'@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709
|
||||
'@silverhand/ts-config': 0.10.2_typescript@4.6.3
|
||||
'@types/jest': 27.4.1
|
||||
'@types/node': 16.11.12
|
||||
eslint: 8.10.0
|
||||
jest: 27.5.1
|
||||
jest-matcher-specific-error: 1.0.0
|
||||
lint-staged: 11.2.6
|
||||
nock: 13.2.2
|
||||
prettier: 2.5.1
|
||||
ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea
|
||||
tsc-watch: 4.6.2_typescript@4.6.3
|
||||
typescript: 4.6.3
|
||||
|
||||
packages/console:
|
||||
specifiers:
|
||||
'@logto/phrases': ^0.1.0
|
||||
|
|
Loading…
Reference in a new issue