From e56a4894b792f175048001159d79de47986e8e80 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 28 Apr 2022 15:55:49 +0800 Subject: [PATCH] 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() --- packages/connector-wechat-native/README.md | 2 + .../docs/config-template.md | 6 + .../connector-wechat-native/jest.config.ts | 8 + packages/connector-wechat-native/package.json | 57 +++++++ .../connector-wechat-native/src/constant.ts | 39 +++++ .../connector-wechat-native/src/index.test.ts | 151 ++++++++++++++++++ packages/connector-wechat-native/src/index.ts | 137 ++++++++++++++++ packages/connector-wechat-native/src/mock.ts | 4 + .../tsconfig.base.json | 10 ++ .../tsconfig.build.json | 5 + .../connector-wechat-native/tsconfig.json | 7 + .../tsconfig.test.json | 6 + packages/connector-wechat/README.md | 2 + .../connector-wechat/docs/config-template.md | 6 + packages/connector-wechat/jest.config.ts | 8 + packages/connector-wechat/package.json | 57 +++++++ packages/connector-wechat/src/constant.ts | 39 +++++ packages/connector-wechat/src/index.test.ts | 151 ++++++++++++++++++ packages/connector-wechat/src/index.ts | 138 ++++++++++++++++ packages/connector-wechat/src/mock.ts | 4 + packages/connector-wechat/tsconfig.base.json | 10 ++ packages/connector-wechat/tsconfig.build.json | 5 + packages/connector-wechat/tsconfig.json | 7 + packages/connector-wechat/tsconfig.test.json | 6 + pnpm-lock.yaml | 94 +++++++++++ 25 files changed, 959 insertions(+) create mode 100644 packages/connector-wechat-native/README.md create mode 100644 packages/connector-wechat-native/docs/config-template.md create mode 100644 packages/connector-wechat-native/jest.config.ts create mode 100644 packages/connector-wechat-native/package.json create mode 100644 packages/connector-wechat-native/src/constant.ts create mode 100644 packages/connector-wechat-native/src/index.test.ts create mode 100644 packages/connector-wechat-native/src/index.ts create mode 100644 packages/connector-wechat-native/src/mock.ts create mode 100644 packages/connector-wechat-native/tsconfig.base.json create mode 100644 packages/connector-wechat-native/tsconfig.build.json create mode 100644 packages/connector-wechat-native/tsconfig.json create mode 100644 packages/connector-wechat-native/tsconfig.test.json create mode 100644 packages/connector-wechat/README.md create mode 100644 packages/connector-wechat/docs/config-template.md create mode 100644 packages/connector-wechat/jest.config.ts create mode 100644 packages/connector-wechat/package.json create mode 100644 packages/connector-wechat/src/constant.ts create mode 100644 packages/connector-wechat/src/index.test.ts create mode 100644 packages/connector-wechat/src/index.ts create mode 100644 packages/connector-wechat/src/mock.ts create mode 100644 packages/connector-wechat/tsconfig.base.json create mode 100644 packages/connector-wechat/tsconfig.build.json create mode 100644 packages/connector-wechat/tsconfig.json create mode 100644 packages/connector-wechat/tsconfig.test.json diff --git a/packages/connector-wechat-native/README.md b/packages/connector-wechat-native/README.md new file mode 100644 index 000000000..f8641b378 --- /dev/null +++ b/packages/connector-wechat-native/README.md @@ -0,0 +1,2 @@ +### WeChat Native Social Connector README +placeholder diff --git a/packages/connector-wechat-native/docs/config-template.md b/packages/connector-wechat-native/docs/config-template.md new file mode 100644 index 000000000..7a650980e --- /dev/null +++ b/packages/connector-wechat-native/docs/config-template.md @@ -0,0 +1,6 @@ +```json +{ + "appId": "", + "appSecret": "" +} +``` diff --git a/packages/connector-wechat-native/jest.config.ts b/packages/connector-wechat-native/jest.config.ts new file mode 100644 index 000000000..d8ceb361c --- /dev/null +++ b/packages/connector-wechat-native/jest.config.ts @@ -0,0 +1,8 @@ +import { Config, merge } from '@logto/jest-config'; + +const config: Config.InitialOptions = merge({ + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-matcher-specific-error'], +}); + +export default config; diff --git a/packages/connector-wechat-native/package.json b/packages/connector-wechat-native/package.json new file mode 100644 index 000000000..0cda43f6c --- /dev/null +++ b/packages/connector-wechat-native/package.json @@ -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. ", + "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" +} diff --git a/packages/connector-wechat-native/src/constant.ts b/packages/connector-wechat-native/src/constant.ts new file mode 100644 index 000000000..4eaaaea51 --- /dev/null +++ b/packages/connector-wechat-native/src/constant.ts @@ -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; + +// 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; diff --git a/packages/connector-wechat-native/src/index.test.ts b/packages/connector-wechat-native/src/index.test.ts new file mode 100644 index 000000000..4c3d83a5a --- /dev/null +++ b/packages/connector-wechat-native/src/index.test.ts @@ -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; + +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: '', + 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)); + }); +}); diff --git a/packages/connector-wechat-native/src/index.ts b/packages/connector-wechat-native/src/index.ts new file mode 100644 index 000000000..2fce1faef --- /dev/null +++ b/packages/connector-wechat-native/src/index.ts @@ -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; + + constructor(getConnectorConfig: GetConnectorConfig) { + 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(); + + 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(); + + 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; + } + }; +} diff --git a/packages/connector-wechat-native/src/mock.ts b/packages/connector-wechat-native/src/mock.ts new file mode 100644 index 000000000..57d6c52ce --- /dev/null +++ b/packages/connector-wechat-native/src/mock.ts @@ -0,0 +1,4 @@ +export const mockedConfig = { + appId: '', + appSecret: '', +}; diff --git a/packages/connector-wechat-native/tsconfig.base.json b/packages/connector-wechat-native/tsconfig.base.json new file mode 100644 index 000000000..848a915f7 --- /dev/null +++ b/packages/connector-wechat-native/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-wechat-native/tsconfig.build.json b/packages/connector-wechat-native/tsconfig.build.json new file mode 100644 index 000000000..d42923dd3 --- /dev/null +++ b/packages/connector-wechat-native/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-wechat-native/tsconfig.json b/packages/connector-wechat-native/tsconfig.json new file mode 100644 index 000000000..20354364a --- /dev/null +++ b/packages/connector-wechat-native/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "types": ["node", "jest", "jest-matcher-specific-error"] + }, + "include": ["src", "jest.config.ts"] +} diff --git a/packages/connector-wechat-native/tsconfig.test.json b/packages/connector-wechat-native/tsconfig.test.json new file mode 100644 index 000000000..98c16f367 --- /dev/null +++ b/packages/connector-wechat-native/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/packages/connector-wechat/README.md b/packages/connector-wechat/README.md new file mode 100644 index 000000000..264dfdc6f --- /dev/null +++ b/packages/connector-wechat/README.md @@ -0,0 +1,2 @@ +### WeChat Web Social Connector README +placeholder diff --git a/packages/connector-wechat/docs/config-template.md b/packages/connector-wechat/docs/config-template.md new file mode 100644 index 000000000..7a650980e --- /dev/null +++ b/packages/connector-wechat/docs/config-template.md @@ -0,0 +1,6 @@ +```json +{ + "appId": "", + "appSecret": "" +} +``` diff --git a/packages/connector-wechat/jest.config.ts b/packages/connector-wechat/jest.config.ts new file mode 100644 index 000000000..d8ceb361c --- /dev/null +++ b/packages/connector-wechat/jest.config.ts @@ -0,0 +1,8 @@ +import { Config, merge } from '@logto/jest-config'; + +const config: Config.InitialOptions = merge({ + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-matcher-specific-error'], +}); + +export default config; diff --git a/packages/connector-wechat/package.json b/packages/connector-wechat/package.json new file mode 100644 index 000000000..3a7d20a86 --- /dev/null +++ b/packages/connector-wechat/package.json @@ -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. ", + "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" +} diff --git a/packages/connector-wechat/src/constant.ts b/packages/connector-wechat/src/constant.ts new file mode 100644 index 000000000..f9edb5e6f --- /dev/null +++ b/packages/connector-wechat/src/constant.ts @@ -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; + +// 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; diff --git a/packages/connector-wechat/src/index.test.ts b/packages/connector-wechat/src/index.test.ts new file mode 100644 index 000000000..669331193 --- /dev/null +++ b/packages/connector-wechat/src/index.test.ts @@ -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; + +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: '', + 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) + ); + }); +}); diff --git a/packages/connector-wechat/src/index.ts b/packages/connector-wechat/src/index.ts new file mode 100644 index 000000000..95605a609 --- /dev/null +++ b/packages/connector-wechat/src/index.ts @@ -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; + + constructor(getConnectorConfig: GetConnectorConfig) { + 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(); + + 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(); + + 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; + } + }; +} diff --git a/packages/connector-wechat/src/mock.ts b/packages/connector-wechat/src/mock.ts new file mode 100644 index 000000000..57d6c52ce --- /dev/null +++ b/packages/connector-wechat/src/mock.ts @@ -0,0 +1,4 @@ +export const mockedConfig = { + appId: '', + appSecret: '', +}; diff --git a/packages/connector-wechat/tsconfig.base.json b/packages/connector-wechat/tsconfig.base.json new file mode 100644 index 000000000..848a915f7 --- /dev/null +++ b/packages/connector-wechat/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-wechat/tsconfig.build.json b/packages/connector-wechat/tsconfig.build.json new file mode 100644 index 000000000..d42923dd3 --- /dev/null +++ b/packages/connector-wechat/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-wechat/tsconfig.json b/packages/connector-wechat/tsconfig.json new file mode 100644 index 000000000..20354364a --- /dev/null +++ b/packages/connector-wechat/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "types": ["node", "jest", "jest-matcher-specific-error"] + }, + "include": ["src", "jest.config.ts"] +} diff --git a/packages/connector-wechat/tsconfig.test.json b/packages/connector-wechat/tsconfig.test.json new file mode 100644 index 000000000..98c16f367 --- /dev/null +++ b/packages/connector-wechat/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdc21ee13..111e9d4d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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