0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(connector): add connector-wecom (#5411)

Co-authored-by: dove <dove@feegr.cc>
This commit is contained in:
doveyoung 2024-02-28 10:23:41 +08:00 committed by GitHub
parent e0dddb1142
commit 697cdd54ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 706 additions and 0 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
node_modules
/.pnp
.pnp.js
.pnpm-store
# testing
/packages/**/coverage

View file

@ -0,0 +1,49 @@
# WeCom OAuth2 connector
The custom connector for WeCom (maybe called WXwork) social sign-in.
I suggest using a different sign-in method if available. Logging in from WeCom is not user-friendly enough.
## Get started
Sign in to [WeCom WebUI](https://work.weixin.qq.com/) with an admin account or click **Manage the enterprise(管理企业)** from the WeCom app.
In the "App Management" tab, scroll the page down and click "Create an app".
Fill in the appropriate information according to your app. Then create.
Now we have the Agent ID (NOT APPID) and Secret.
### Website info
Set the things you need on this page. It would be like:
- Allowed users: _who can see this app_
- App Homepage: _Your app homepage. E.g., `logto.io/demo-app`_
**Important**
There are three items on this page regarding the "Developer API(开发者接口)".
1. Web Authorization and JS-SDK;
2. Log in to via authorization by WeCom;
3. Enterprise Trusted IP;
Fill them according to the guide of WeCom.
> Maybe you should do something according to the guide of WeCom who should act according to the requirements of some others. God bless you.
### Corp ID
If you are familiar with WeChat development, you may notice that the use of Corp ID is the same as APP ID.
You can find the Corp ID at the bottom of the "My Enterprise(我的企业)" tab page. It seems like **ww\*\*\*\*** .
## Configure the connector
So we have the Agent ID, Secret, and Corp ID.
Let's complete the form for the connector.
You can leave the `Scope` field blank as it is optional. Alternatively, you can fill in `snsapi_base` or `snsapi_privateinfo`. I apologize for not testing it fully.
**Save and done**

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="65" height="56" viewBox="0 0 65 56" enable-background="new 0 0 65 56" xml:space="preserve"> <image id="image0" width="65" height="56" x="0" y="0"
href="data:image/png;base64,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" ></image>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -0,0 +1,50 @@
{
"name": "@logto/connector-wecom",
"version": "0.1.0",
"description": "Wecom connector implementation.",
"author": "Dove<dove@feegr.cc> fork from Wechat Web connector",
"dependencies": {
"@logto/connector-kit": "workspace:^2.0.0"
},
"main": "./lib/index.js",
"module": "./lib/index.js",
"exports": "./lib/index.js",
"license": "MPL-2.0",
"type": "module",
"files": [
"lib",
"docs",
"logo.svg"
],
"scripts": {
"precommit": "lint-staged",
"build:test": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
"build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build:test && pnpm test:only",
"test:ci": "pnpm test:only --silent --coverage",
"prepublishOnly": "pnpm build"
},
"engines": {
"node": "^20.9.0"
},
"eslintConfig": {
"extends": "@silverhand",
"settings": {
"import/core-modules": [
"@silverhand/essentials",
"got",
"nock",
"snakecase-keys",
"zod"
]
}
},
"prettier": "@silverhand/eslint-config/.prettierrc",
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,66 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorConfigFormItemType } from '@logto/connector-kit';
export const authorizationEndpointInside = 'https://open.weixin.qq.com/connect/oauth2/authorize';
export const authorizationEndpointQrcode = 'https://open.work.weixin.qq.com/wwopen/sso/qrConnect';
export const accessTokenEndpoint = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken';
export const userInfoEndpoint = 'https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo';
export const scope = 'snsapi_userinfo';
// See https://developer.work.weixin.qq.com/document/path/90313 to know more about WeCom response error code
export const invalidAuthCodeErrcode = [40_029, 40_163, 42_003];
export const invalidAccessTokenErrcode = [40_001, 40_014];
export const defaultMetadata: ConnectorMetadata = {
id: 'wecom-universal',
target: 'wecom',
platform: null,
name: {
en: 'WeCom',
'zh-CN': '企业微信',
'tr-TR': 'WeCom',
ko: 'WeCom',
},
logo: './logo.svg',
logoDark: null,
description: {
en: 'WeCom is a cross-platform instant messaging app for team. It is the enterprise version of WeChat.',
'zh-CN': '企业微信,是腾讯微信团队为企业打造的专业办公管理工具。',
},
readme: './README.md',
formItems: [
{
key: 'corpId',
label: 'Corp ID',
required: true,
type: ConnectorConfigFormItemType.Text,
placeholder: '<app-id>',
},
{
key: 'appSecret',
label: 'App Secret',
required: true,
type: ConnectorConfigFormItemType.Text,
placeholder: '<app-secret>',
},
{
key: 'agentId',
label: 'Agent ID',
required: true,
type: ConnectorConfigFormItemType.Text,
placeholder: '<agent-id>',
},
{
key: 'scope',
type: ConnectorConfigFormItemType.Text,
label: 'Scope',
required: false,
placeholder: '<scope>',
description:
"The `scope` determines permissions granted by the user's authorization. If you are not sure what to enter, do not worry, just leave it blank.",
},
],
};
export const defaultTimeout = 5000;

View file

@ -0,0 +1,218 @@
import nock from 'nock';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import {
accessTokenEndpoint,
authorizationEndpointInside,
authorizationEndpointQrcode,
userInfoEndpoint,
} from './constant.js';
import createConnector, { getAccessToken } from './index.js';
import { mockedConfig } from './mock.js';
const { jest } = import.meta;
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
describe('getAuthorizationUri', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get a valid uri by redirectUri and state', async () => {
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri(
{
state: 'some_state',
redirectUri: 'http://localhost:3001/callback',
connectorId: 'some_connector_id',
connectorFactoryId: 'some_connector_factory_id',
jti: 'some_jti',
headers: {},
},
jest.fn()
);
const userAgent = 'some_UA';
const isWecom = userAgent.toLowerCase().includes('wxwork');
const authorizationEndpoint = isWecom
? authorizationEndpointInside
: authorizationEndpointQrcode;
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?appid=%3Ccorp-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&response_type=code&scope=snsapi_userinfo&state=some_state&agentid=%3Cagent-id%3E#wechat_redirect`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
corpid: '<corp-id>',
corpsecret: '<app-secret>',
});
it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
});
const { accessToken } = await getAccessToken(mockedConfig);
expect(accessToken).toEqual('access_token');
// Expect(openid).toEqual('openid');
});
it('throws SocialAuthCodeInvalid error if errcode is 40029', async () => {
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_029, errmsg: 'invalid code' });
await expect(getAccessToken(mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code')
);
});
it('throws SocialAuthCodeInvalid error if errcode is 40163', async () => {
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(true)
.reply(200, { errcode: 40_163, errmsg: 'code been used' });
await expect(getAccessToken(mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'code been used')
);
});
it('throws error with message otherwise', async () => {
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(true)
.reply(200, { errcode: -1, errmsg: 'system error' });
await expect(getAccessToken(mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'system error',
errcode: -1,
})
);
});
});
const nockNoOpenIdAccessTokenResponse = () => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
nock(accessTokenEndpointUrl.origin).get(accessTokenEndpointUrl.pathname).query(true).reply(200, {
access_token: 'access_token',
});
};
describe('getUserInfo', () => {
beforeEach(() => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
corpid: '<corp-id>',
corpsecret: '<app-secret>',
});
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
});
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
const userInfoEndpointUrl = new URL(userInfoEndpoint);
const parameters = new URLSearchParams({ access_token: 'access_token', code: 'code' });
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
userid: 'wecom_id',
});
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo(
{
code: 'code',
},
jest.fn()
);
expect(socialUserInfo).toMatchObject({
id: 'wecom_id',
avatar: '',
name: 'wecom_id',
});
});
it('throws General error if code not provided in input', async () => {
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
});
it('throws error if `openid` is missing', async () => {
nockNoOpenIdAccessTokenResponse();
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, {
errcode: 41_009,
errmsg: 'missing openid',
});
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'missing openid',
errcode: 41_009,
})
);
});
it('throws SocialAccessTokenInvalid error if errcode is 40001', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_001, errmsg: 'invalid credential' });
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid credential')
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).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' });
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'invalid openid',
errcode: 40_003,
})
);
});
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
});

View file

@ -0,0 +1,196 @@
/**
* 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 { assert } from '@silverhand/essentials';
import { got, HTTPError } from 'got';
import type {
GetAuthorizationUri,
GetUserInfo,
GetConnectorConfig,
CreateConnector,
SocialConnector,
} from '@logto/connector-kit';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
parseJson,
} from '@logto/connector-kit';
import {
authorizationEndpointInside,
authorizationEndpointQrcode,
accessTokenEndpoint,
userInfoEndpoint,
scope as defaultScope,
defaultMetadata,
defaultTimeout,
invalidAccessTokenErrcode,
invalidAuthCodeErrcode,
} from './constant.js';
import type {
GetAccessTokenErrorHandler,
UserInfoResponseMessageParser,
WecomConfig,
} from './types.js';
import {
wecomConfigGuard,
accessTokenResponseGuard,
userInfoResponseGuard,
authResponseGuard,
} from './types.js';
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri, headers: { userAgent } }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, wecomConfigGuard);
// WeCom has two different authorizationEndpoint, one for the WeCom built-in browser and another for regular web browsers.
const isWecom: boolean | undefined = userAgent?.toLowerCase().includes('wxwork');
const { corpId, scope, agentId } = config;
const queryParameters = new URLSearchParams({
appid: corpId,
redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret}
response_type: 'code',
scope: scope ?? defaultScope,
state,
agentid: agentId,
});
// We switch to different authorizationEndpoint based on the value of isWecom
const authorizationEndpoint = isWecom
? authorizationEndpointInside
: authorizationEndpointQrcode;
return `${authorizationEndpoint}?${queryParameters.toString()}#wechat_redirect`;
};
export const getAccessToken = async (config: WecomConfig): Promise<{ accessToken: string }> => {
const { corpId: corpid, appSecret: corpsecret } = config;
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: { corpid, corpsecret },
timeout: { request: defaultTimeout },
});
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
const { access_token: accessToken } = result.data;
getAccessTokenErrorHandler(result.data);
assert(accessToken, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
return { accessToken };
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig(config, wecomConfigGuard);
const { accessToken } = await getAccessToken(config);
try {
const httpResponse = await got.get(userInfoEndpoint, {
searchParams: { access_token: accessToken, code },
timeout: { request: defaultTimeout },
});
const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
errorResponseHandler(result.data);
//
const { userid, openid } = result.data;
if (userid) {
return { id: userid, avatar: '', name: userid };
}
if (openid) {
return { id: openid, avatar: '', name: openid };
}
throw new Error('Both userid and openid are undefined or null.');
// Both userid and openid are null
} catch (error: unknown) {
return getUserInfoErrorHandler(error);
}
};
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
const errorResponseHandler: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
};
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createWecomConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: wecomConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
export default createWecomConnector;

View file

@ -0,0 +1,6 @@
export const mockedConfig = {
corpId: '<corp-id>',
appSecret: '<app-secret>',
agentId: '<agent-id>',
access_token: 'access_token',
};

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
export const wecomConfigGuard = z.object({
corpId: z.string(),
appSecret: z.string(),
scope: z.string().optional(),
agentId: z.string(),
});
export type WecomConfig = z.infer<typeof wecomConfigGuard>;
export const accessTokenResponseGuard = z.object({
access_token: z.string().optional(),
expires_in: z.number().optional(), // In seconds
errcode: z.number().optional(),
errmsg: z.string().optional(),
});
export type AccessTokenResponse = z.infer<typeof accessTokenResponseGuard>;
export type GetAccessTokenErrorHandler = (accessToken: Partial<AccessTokenResponse>) => void;
export const userInfoResponseGuard = z.object({
errcode: z.number().optional(),
errmsg: z.string().optional(),
userid: z.string().optional(),
user_ticket: z.string().optional(),
openid: z.string().optional(),
external_userid: z.string().optional(),
});
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
export const authResponseGuard = z.object({ code: z.string() });

View file

@ -2828,6 +2828,85 @@ importers:
specifier: ^5.3.3
version: 5.3.3
packages/connectors/connector-wecom:
dependencies:
'@logto/connector-kit':
specifier: workspace:^2.0.0
version: link:../../toolkit/connector-kit
'@silverhand/essentials':
specifier: ^2.9.0
version: 2.9.0
got:
specifier: ^14.0.0
version: 14.0.0
snakecase-keys:
specifier: ^6.0.0
version: 6.0.0
zod:
specifier: ^3.22.4
version: 3.22.4
devDependencies:
'@jest/types':
specifier: ^29.5.0
version: 29.6.3
'@rollup/plugin-commonjs':
specifier: ^25.0.0
version: 25.0.7(rollup@4.12.0)
'@rollup/plugin-json':
specifier: ^6.0.0
version: 6.1.0(rollup@4.12.0)
'@rollup/plugin-node-resolve':
specifier: ^15.0.1
version: 15.2.3(rollup@4.12.0)
'@rollup/plugin-typescript':
specifier: ^11.0.0
version: 11.1.6(rollup@4.12.0)(typescript@5.3.3)
'@silverhand/eslint-config':
specifier: 5.0.0
version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3)
'@silverhand/ts-config':
specifier: 5.0.0
version: 5.0.0(typescript@5.3.3)
'@types/jest':
specifier: ^29.4.0
version: 29.4.0
'@types/node':
specifier: ^20.9.5
version: 20.10.4
'@types/supertest':
specifier: ^6.0.0
version: 6.0.1
eslint:
specifier: ^8.44.0
version: 8.44.0
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.10.4)
jest-matcher-specific-error:
specifier: ^1.0.0
version: 1.0.0
lint-staged:
specifier: ^15.0.0
version: 15.0.2
nock:
specifier: ^13.2.2
version: 13.3.1
prettier:
specifier: ^3.0.0
version: 3.0.0
rollup:
specifier: ^4.0.0
version: 4.12.0
rollup-plugin-summary:
specifier: ^2.0.0
version: 2.0.0(rollup@4.12.0)
supertest:
specifier: ^6.2.2
version: 6.2.2
typescript:
specifier: ^5.3.3
version: 5.3.3
packages/console:
devDependencies:
'@fontsource/roboto-mono':