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:
parent
e0dddb1142
commit
697cdd54ee
10 changed files with 706 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,6 +4,7 @@
|
|||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.pnpm-store
|
||||
|
||||
# testing
|
||||
/packages/**/coverage
|
||||
|
|
49
packages/connectors/connector-wecom/README.md
Normal file
49
packages/connectors/connector-wecom/README.md
Normal 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**
|
5
packages/connectors/connector-wecom/logo.svg
Normal file
5
packages/connectors/connector-wecom/logo.svg
Normal 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 |
50
packages/connectors/connector-wecom/package.json
Normal file
50
packages/connectors/connector-wecom/package.json
Normal 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"
|
||||
}
|
||||
}
|
66
packages/connectors/connector-wecom/src/constant.ts
Normal file
66
packages/connectors/connector-wecom/src/constant.ts
Normal 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;
|
218
packages/connectors/connector-wecom/src/index.test.ts
Normal file
218
packages/connectors/connector-wecom/src/index.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
196
packages/connectors/connector-wecom/src/index.ts
Normal file
196
packages/connectors/connector-wecom/src/index.ts
Normal 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;
|
6
packages/connectors/connector-wecom/src/mock.ts
Normal file
6
packages/connectors/connector-wecom/src/mock.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const mockedConfig = {
|
||||
corpId: '<corp-id>',
|
||||
appSecret: '<app-secret>',
|
||||
agentId: '<agent-id>',
|
||||
access_token: 'access_token',
|
||||
};
|
36
packages/connectors/connector-wecom/src/types.ts
Normal file
36
packages/connectors/connector-wecom/src/types.ts
Normal 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() });
|
|
@ -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':
|
||||
|
|
Loading…
Reference in a new issue