mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(connector): add dingtalk connector (#5915)
* feat: add dingtalk connector * refactor(connector): optimize codes * refactor(connector): optimize the logic of getting user phone * docs(connector): add English configuration guide for DingTalk * docs(connector): add table of contents * docs(connector): optimize format --------- Co-authored-by: aidenlu <aiden_lu@wochacha.com>
This commit is contained in:
parent
458746c9ac
commit
87bffee3f4
9 changed files with 683 additions and 1 deletions
144
packages/connectors/connector-dingtalk-web/README.md
Normal file
144
packages/connectors/connector-dingtalk-web/README.md
Normal file
|
@ -0,0 +1,144 @@
|
|||
# DingTalk Web Connector
|
||||
|
||||
The official Logto connector for DingTalk social sign-in in web apps.
|
||||
|
||||
钉钉 web 应用社交登录官方 Logto 连接器 [中文文档](#钉钉网页连接器)
|
||||
|
||||
**Table of contents**
|
||||
|
||||
- [DingTalk Web Connector](#dingtalk-web-connector)
|
||||
- [Get Started](#get-started)
|
||||
- [Create a Web App in the DingTalk Open Platform](#create-a-web-app-in-the-dingtalk-open-platform)
|
||||
- [Register a DingTalk Developer Account](#register-a-dingtalk-developer-account)
|
||||
- [Create an Application](#create-an-application)
|
||||
- [Configure Permissions](#configure-permissions)
|
||||
- [Configure Your Connector](#configure-your-connector)
|
||||
- [Config Types](#config-types)
|
||||
- [Test DingTalk Connector](#test-dingtalk-connector)
|
||||
- [Support](#support)
|
||||
- [钉钉网页连接器](#钉钉网页连接器)
|
||||
- [开始上手](#开始上手)
|
||||
- [在钉钉开放平台新建一个应用](#在钉钉开放平台新建一个应用)
|
||||
- [注册钉钉开发者账号](#注册钉钉开发者账号)
|
||||
- [创建应用](#创建应用)
|
||||
- [配置权限](#配置权限)
|
||||
- [配置你的连接器](#配置你的连接器)
|
||||
- [配置类型](#配置类型)
|
||||
- [测试钉钉连接器](#测试钉钉连接器)
|
||||
- [支持](#支持)
|
||||
|
||||
## Get started
|
||||
|
||||
The DingTalk web connector is designed for desktop web applications. It uses the OAuth 2.0 authentication flow.
|
||||
|
||||
## Create a web app in the DingTalk Open Platform
|
||||
|
||||
> 💡 **Tip**
|
||||
>
|
||||
> You can skip some sections if you have already finished.
|
||||
|
||||
### Register a DingTalk developer account
|
||||
|
||||
If you do not have a DingTalk developer account, please register at the [DingTalk Open Platform](https://open.dingtalk.com).
|
||||
|
||||
### Create an application
|
||||
|
||||
1. In the [DingTalk Developer Console](https://open-dev.dingtalk.com/console/index), click "Create Application"
|
||||
2. Choose "Self-built Application", fill in the application name and basic information, and click "Create"
|
||||
3. In the left navigation bar, select "Development Configuration" -> "Security Settings", find and configure the "Redirect URL" `${your_logto_origin}/callback/${connector_id}`. You can find the `connector_id` on the connector details page after adding the respective connector in the management console
|
||||
4. In the left navigation bar, select "Basic Information" -> "Credentials and Basic Information" to get the "Client ID" and "Client Secret"
|
||||
5. In the left navigation bar, select "Application Release" -> "Version Management and Release", create and release the first version to activate the "Client ID" and "Client Secret"
|
||||
|
||||
> ℹ️ **Note**
|
||||
> If the application does not release a version, the obtained "Client ID" and "Client Secret" cannot be used, or requests will fail.
|
||||
|
||||
### Configure permissions
|
||||
|
||||
1. In "Development Configuration" -> "Permission Management", select `Contact.User.Read` and `Contact.User.mobile` permissions and authorize them
|
||||
2. After confirming the permission configuration, click "Save" and publish the application
|
||||
|
||||
## Configure your connector
|
||||
|
||||
Fill out the `clientId` and `clientSecret` field with _Client ID(formerly AppKey and SuiteKey)_ and _Client Secret(formerly AppKey and SuiteKey)_ you've got from OAuth app detail pages mentioned in the previous section.
|
||||
|
||||
`scope` currently supports two values: `openid` and `openid corpid`. `openid` allows obtaining the user's `userid` after authorization, while `openid corpid` allows obtaining both the user's `id` and the organization `id` selected during the login process. The values should be space-delimited. Note: URL encoding is required.
|
||||
|
||||
### Config types
|
||||
|
||||
| Name | Type |
|
||||
|--------------|--------|
|
||||
| clientId | string |
|
||||
| clientSecret | string |
|
||||
| scope | string |
|
||||
|
||||
## Test DingTalk connector
|
||||
|
||||
That's it. The DingTalk connector should be available now. Don't forget to [Enable connector in sign-in experience](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-social-sign-in).
|
||||
|
||||
Once DingTalk web connector is enabled, you can sign in to your app again to see if it works.
|
||||
|
||||
> ℹ️ **Note**
|
||||
> Please ensure strict compliance with the usage specifications and development guidelines of the DingTalk Open Platform during the development process.
|
||||
|
||||
## Support
|
||||
|
||||
If you have any questions or need further assistance, please visit the [DingTalk Developer Documentation](https://open.dingtalk.com/document/orgapp/obtain-identity-credentials) or contact DingTalk technical support.
|
||||
|
||||
# 钉钉网页连接器
|
||||
|
||||
## 开始上手
|
||||
|
||||
钉钉网页连接器是为桌面网页应用设计的。它采用了 OAuth 2.0 认证流程。
|
||||
|
||||
## 在钉钉开放平台新建一个应用
|
||||
|
||||
> 💡 **Tip**
|
||||
>
|
||||
> 你可以跳过已经完成的部分。
|
||||
|
||||
### 注册钉钉开发者账号
|
||||
|
||||
如果你还没有钉钉开发者账号,请在 [钉钉开放平台](https://open.dingtalk.com) 注册。
|
||||
|
||||
### 创建应用
|
||||
|
||||
1. 在 [钉钉开发者后台](https://open-dev.dingtalk.com/console/index) 中,点击「创建应用」
|
||||
2. 选择「自建应用」,填写应用名称和基本信息,点击「创建」
|
||||
3. 在左侧导航栏选择「开发配置」->「安全设置」,找到并配置「重定向 URL」 `${your_logto_origin}/callback/${connector_id}`。其中 `connector_id` 在管理控制台添加了相应的连接器之后,可以在连接器的详情页中找到
|
||||
4. 在左侧导航栏选择「基础信息」->「凭证与基础信息」中可以获取「Client ID」、「Client Secret」
|
||||
5. 在左侧导航栏选择「应用发布」->「版本管理与发布」,创建并发布第一个版本,以使「Client ID」、「Client Secret」生效
|
||||
|
||||
> ℹ️ **Note**
|
||||
> 应用不发布版本,所获取的「Client ID」、「Client Secret」 均无法使用,或请求错误。
|
||||
|
||||
### 配置权限
|
||||
|
||||
1. 在「开发配置」->「权限管理」中,选择`通讯录个人信息读权限`和`个人手机号信息`权限并进行授权
|
||||
2. 确认权限配置后,点击「保存」并发布应用
|
||||
|
||||
## 配置你的连接器
|
||||
|
||||
在 clientId 和 clientSecret 字段中填入你在上一个部分中提到的 OAuth 应用详情页面获取的 Client ID(原 AppKey 和 SuiteKey) 和 Client Secret(原 AppKey 和 SuiteKey) 。
|
||||
|
||||
scope 目前支持两种值:openid 和 openid corpid。openid 授权后可以获取用户的 userid,而 openid corpid 授权后可以获取用户的 id 和登录过程中用户选择的组织 id。这些值应以空格分隔。注意:需要进行 URL 编码。
|
||||
|
||||
### 配置类型
|
||||
|
||||
| Name | Type |
|
||||
|--------------|--------|
|
||||
| clientId | string |
|
||||
| clientSecret | string |
|
||||
| scope | string |
|
||||
|
||||
## 测试钉钉连接器
|
||||
|
||||
大功告成。别忘了 [在登录体验中启用本连接器](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-social-sign-in)。
|
||||
|
||||
在钉钉web连接器启用后,你可以构建并运行你的应用看看是否生效。
|
||||
|
||||
> ℹ️ **Note**
|
||||
> 请确保在开发过程中,严格遵守钉钉开放平台的使用规范和开发指南。
|
||||
|
||||
## 支持
|
||||
|
||||
如有任何问题或需进一步帮助,请访问 [钉钉开发者文档](https://open.dingtalk.com/document/orgapp/obtain-identity-credentials) 或联系钉钉技术支持。
|
4
packages/connectors/connector-dingtalk-web/logo.svg
Normal file
4
packages/connectors/connector-dingtalk-web/logo.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" image-rendering="optimizeSpeed">
|
||||
<path fill="#1791f5" d="M58.182 25.136a25.576 25.576 0 0 0-2.016-5.354 27.163 27.163 0 0 0-.704-1.282h-.001q-1.751-2.975-4.367-5.591C45.824 7.638 39.456 5 32 5S18.18 7.638 12.908 12.91C7.638 18.18 5 24.544 5 32c0 7.456 2.637 13.823 7.908 19.094 3.893 3.893 8.38 6.347 13.437 7.359A28.66 28.66 0 0 0 32 59c7.457 0 13.823-2.634 19.094-7.905 2.78-2.78 4.828-5.863 6.14-9.256.973-2.514 1.545-5.194 1.714-8.051.034-.584.052-1.181.052-1.788 0-2.388-.27-4.668-.818-6.864z"/>
|
||||
<path fill="#fff" d="M16.843 14.608c0 2.747.375 6.646 2.35 8.773 1.06 1.141 2.807 1.451 4.21 1.956 3.065 1.104 6.458 2.783 9.726 3.069l-4.298-.631-9.726-1.857c.02.98.219 1.805.59 2.714 2.43 5.974 8.3 4.75 13.66 4.75-2.02.614-4.245.687-6.333.968-.696.094-2.037.118-2.472.774-.733 1.104 1.34 2.622 2.02 3.175 2.967 2.411 5.932 1.95 9.273.511l-1.13 5.655h3.618l-1.81 8.143 4.243-5.428 4.805-6.56h-4.75c1.558-2.928 3.476-5.659 5.026-8.595.68-1.286 1.625-2.777 1.21-4.298-.648-2.369-4.876-3.41-6.914-4.11-5.601-1.92-11.023-4.17-16.512-6.373-2.143-.86-4.453-2.515-6.786-2.636z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
76
packages/connectors/connector-dingtalk-web/package.json
Normal file
76
packages/connectors/connector-dingtalk-web/package.json
Normal file
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "@logto/connector-dingtalk-web",
|
||||
"version": "1.0.0",
|
||||
"description": "Dingtalk web connector implementation.",
|
||||
"dependencies": {
|
||||
"@logto/connector-kit": "workspace:^3.0.0",
|
||||
"@silverhand/essentials": "^2.9.1",
|
||||
"dayjs": "^1.10.5",
|
||||
"got": "^14.0.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"snakecase-keys": "^8.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^25.0.7",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"@shopify/jest-koa-mocks": "^5.0.0",
|
||||
"@silverhand/eslint-config": "6.0.1",
|
||||
"@silverhand/ts-config": "6.0.0",
|
||||
"@types/node": "^20.11.20",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^1.4.0",
|
||||
"eslint": "^8.56.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"nock": "^13.3.1",
|
||||
"prettier": "^3.0.0",
|
||||
"rollup": "^4.12.0",
|
||||
"rollup-plugin-output-size": "^1.3.0",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.4.0"
|
||||
},
|
||||
"main": "./lib/index.js",
|
||||
"module": "./lib/index.js",
|
||||
"exports": "./lib/index.js",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"lib",
|
||||
"docs",
|
||||
"logo.svg",
|
||||
"logo-dark.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": "vitest src",
|
||||
"test:ci": "pnpm run test --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"
|
||||
}
|
||||
}
|
60
packages/connectors/connector-dingtalk-web/src/constant.ts
Normal file
60
packages/connectors/connector-dingtalk-web/src/constant.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import type { ConnectorMetadata } from '@logto/connector-kit';
|
||||
import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit';
|
||||
|
||||
// https://open.dingtalk.com/document/orgapp-server/use-dingtalk-account-to-log-on-to-third-party-websites-1
|
||||
export const authorizationEndpoint = 'https://login.dingtalk.com/oauth2/auth';
|
||||
// https://open.dingtalk.com/document/isvapp/obtain-user-token
|
||||
export const accessTokenEndpoint = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken';
|
||||
// https://open.dingtalk.com/document/isvapp/dingtalk-retrieve-user-information
|
||||
// To obtain the current authorized person's information, the unionId parameter can be set to "me".
|
||||
export const userInfoEndpoint = 'https://api.dingtalk.com/v1.0/contact/users/me';
|
||||
export const scope = 'openid';
|
||||
|
||||
export const defaultMetadata: ConnectorMetadata = {
|
||||
id: 'dingtalk-web',
|
||||
target: 'dingtalk',
|
||||
platform: ConnectorPlatform.Web,
|
||||
name: {
|
||||
en: 'DingTalk',
|
||||
'zh-CN': '钉钉',
|
||||
'tr-TR': 'DingTalk',
|
||||
ko: 'DingTalk',
|
||||
},
|
||||
logo: './logo.svg',
|
||||
logoDark: null,
|
||||
description: {
|
||||
en: 'DingTalk is an enterprise-level intelligent mobile office platform launched by Alibaba Group.',
|
||||
'zh-CN': '钉钉是一个由阿里巴巴集团推出的企业级智能移动办公平台。',
|
||||
'tr-TR':
|
||||
'DingTalk, Alibaba Grubu tarafından piyasaya sürülen kurumsal düzeyde akıllı mobil ofis platformudur.',
|
||||
ko: '딩톡은 알리바바 그룹이 출시한 기업용 지능형 모바일 오피스 플랫폼입니다.',
|
||||
},
|
||||
readme: './README.md',
|
||||
formItems: [
|
||||
{
|
||||
key: 'clientId',
|
||||
label: 'Client ID',
|
||||
required: true,
|
||||
type: ConnectorConfigFormItemType.Text,
|
||||
placeholder: '<client-id>',
|
||||
},
|
||||
{
|
||||
key: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
required: true,
|
||||
type: ConnectorConfigFormItemType.Text,
|
||||
placeholder: '<client-secret>',
|
||||
},
|
||||
{
|
||||
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;
|
119
packages/connectors/connector-dingtalk-web/src/index.test.ts
Normal file
119
packages/connectors/connector-dingtalk-web/src/index.test.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import nock from 'nock';
|
||||
|
||||
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||
|
||||
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js';
|
||||
import createConnector, { getAccessToken } from './index.js';
|
||||
import { mockedConfig } from './mock.js';
|
||||
|
||||
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
|
||||
|
||||
describe('Dingtalk connector', () => {
|
||||
describe('getAuthorizationUri', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get a valid authorizationUri with redirectUri and state', async () => {
|
||||
const connector = await createConnector({ getConfig });
|
||||
const authorizationUri = await connector.getAuthorizationUri(
|
||||
{
|
||||
state: 'some_state',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
connectorId: 'some_connector_id',
|
||||
connectorFactoryId: 'some_connector_factory_id',
|
||||
jti: 'some_jti',
|
||||
headers: {},
|
||||
},
|
||||
vi.fn()
|
||||
);
|
||||
expect(authorizationUri).toEqual(
|
||||
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code&scope=openid&state=some_state&prompt=consent`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get an accessToken by exchanging with code', async () => {
|
||||
nock(accessTokenEndpoint).post('').reply(200, {
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'scope',
|
||||
expires_in: 7200,
|
||||
corpId: 'corpId',
|
||||
});
|
||||
|
||||
const { accessToken } = await getAccessToken('code', mockedConfig);
|
||||
expect(accessToken).toEqual('accessToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
beforeEach(() => {
|
||||
nock(accessTokenEndpoint).post('').reply(200, {
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'scope',
|
||||
expires_in: 7200,
|
||||
corpId: 'corpId',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get valid SocialUserInfo', async () => {
|
||||
nock(userInfoEndpoint).get('').reply(200, {
|
||||
nick: 'zhangsan',
|
||||
avatarUrl: 'https://xxx',
|
||||
mobile: '150xxxx9144',
|
||||
openId: '123',
|
||||
unionId: '123',
|
||||
email: 'zhangsan@alibaba-inc.com',
|
||||
stateCode: '86',
|
||||
});
|
||||
const connector = await createConnector({ getConfig });
|
||||
const socialUserInfo = await connector.getUserInfo(
|
||||
{
|
||||
code: 'code',
|
||||
},
|
||||
vi.fn()
|
||||
);
|
||||
expect(socialUserInfo).toStrictEqual({
|
||||
id: '123',
|
||||
avatar: 'https://xxx',
|
||||
email: 'zhangsan@alibaba-inc.com',
|
||||
name: 'zhangsan',
|
||||
phone: '86150xxxx9144',
|
||||
rawData: {
|
||||
nick: 'zhangsan',
|
||||
avatarUrl: 'https://xxx',
|
||||
mobile: '150xxxx9144',
|
||||
openId: '123',
|
||||
unionId: '123',
|
||||
email: 'zhangsan@alibaba-inc.com',
|
||||
stateCode: '86',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SocialAccessTokenInvalid error if remote response code is 400', async () => {
|
||||
nock(userInfoEndpoint).get('').reply(400);
|
||||
const connector = await createConnector({ getConfig });
|
||||
await expect(connector.getUserInfo({ code: 'code' }, vi.fn())).rejects.toStrictEqual(
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws unrecognized error', async () => {
|
||||
nock(userInfoEndpoint).get('').reply(500);
|
||||
const connector = await createConnector({ getConfig });
|
||||
await expect(connector.getUserInfo({ code: 'code' }, vi.fn())).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
159
packages/connectors/connector-dingtalk-web/src/index.ts
Normal file
159
packages/connectors/connector-dingtalk-web/src/index.ts
Normal file
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* DingTalk OAuth2 Connector
|
||||
* https://open.dingtalk.com/document/orgapp/obtain-identity-credentials#title-4up-u8w-5ug
|
||||
*/
|
||||
|
||||
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 {
|
||||
authorizationEndpoint,
|
||||
accessTokenEndpoint,
|
||||
userInfoEndpoint,
|
||||
scope as defaultScope,
|
||||
defaultMetadata,
|
||||
defaultTimeout,
|
||||
} from './constant.js';
|
||||
import type { DingtalkConfig } from './types.js';
|
||||
import {
|
||||
dingtalkConfigGuard,
|
||||
accessTokenResponseGuard,
|
||||
userInfoResponseGuard,
|
||||
authResponseGuard,
|
||||
} from './types.js';
|
||||
|
||||
const getAuthorizationUri =
|
||||
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
|
||||
async ({ state, redirectUri }) => {
|
||||
const config = await getConfig(defaultMetadata.id);
|
||||
validateConfig(config, dingtalkConfigGuard);
|
||||
|
||||
const { clientId, scope } = config;
|
||||
|
||||
const queryParameters = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {clientId, clientSecret}
|
||||
response_type: 'code',
|
||||
scope: scope ?? defaultScope,
|
||||
state,
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return `${authorizationEndpoint}?${queryParameters.toString()}`;
|
||||
};
|
||||
|
||||
export const getAccessToken = async (
|
||||
code: string,
|
||||
config: DingtalkConfig
|
||||
): Promise<{ accessToken: string }> => {
|
||||
const { clientId, clientSecret } = config;
|
||||
|
||||
const httpResponse = await got.post(accessTokenEndpoint, {
|
||||
json: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
code,
|
||||
grantType: 'authorization_code',
|
||||
},
|
||||
timeout: { request: defaultTimeout },
|
||||
});
|
||||
|
||||
const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body));
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
||||
}
|
||||
|
||||
const { accessToken } = 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, dingtalkConfigGuard);
|
||||
const { accessToken } = await getAccessToken(code, config);
|
||||
|
||||
try {
|
||||
const httpResponse = await got.get(userInfoEndpoint, {
|
||||
headers: {
|
||||
'x-acs-dingtalk-access-token': accessToken,
|
||||
},
|
||||
timeout: { request: defaultTimeout },
|
||||
});
|
||||
const rawData = parseJson(httpResponse.body);
|
||||
const result = userInfoResponseGuard.safeParse(rawData);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
||||
}
|
||||
|
||||
const { nick: name, avatarUrl: avatar, unionId: id, email, mobile, stateCode } = result.data;
|
||||
return {
|
||||
id,
|
||||
avatar,
|
||||
phone: stateCode && mobile ? `${stateCode}${mobile}` : undefined,
|
||||
email,
|
||||
name,
|
||||
rawData,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return getUserInfoErrorHandler(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInfoErrorHandler = (error: unknown) => {
|
||||
// https://open.dingtalk.com/document/personalapp/error-code-2#title-m5s-krt-vds
|
||||
if (error instanceof HTTPError) {
|
||||
const { statusCode, body: rawBody } = error.response;
|
||||
|
||||
if (statusCode === 400) {
|
||||
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 createDingtalkConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Social,
|
||||
configGuard: dingtalkConfigGuard,
|
||||
getAuthorizationUri: getAuthorizationUri(getConfig),
|
||||
getUserInfo: getUserInfo(getConfig),
|
||||
};
|
||||
};
|
||||
|
||||
export default createDingtalkConnector;
|
5
packages/connectors/connector-dingtalk-web/src/mock.ts
Normal file
5
packages/connectors/connector-dingtalk-web/src/mock.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const mockedConfig = {
|
||||
clientId: '<client-id>',
|
||||
clientSecret: '<client-secret>',
|
||||
scode: '<scope>',
|
||||
};
|
33
packages/connectors/connector-dingtalk-web/src/types.ts
Normal file
33
packages/connectors/connector-dingtalk-web/src/types.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const dingtalkConfigGuard = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DingtalkConfig = z.infer<typeof dingtalkConfigGuard>;
|
||||
|
||||
export const accessTokenResponseGuard = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().optional(),
|
||||
expireIn: z.number().optional(), // In seconds
|
||||
corpId: z.string().optional(),
|
||||
});
|
||||
|
||||
// https://open.dingtalk.com/document/isvapp/dingtalk-retrieve-user-information
|
||||
export const userInfoResponseGuard = z.object({
|
||||
nick: z.string().optional(),
|
||||
avatarUrl: z.string().optional(),
|
||||
mobile: z.string().optional(),
|
||||
openId: z.string().optional(), // DingTalk no longer recommends using OpenId for integration. Instead, use unionId.
|
||||
unionId: z.string(),
|
||||
email: z.string().optional(),
|
||||
stateCode: 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() });
|
|
@ -754,6 +754,88 @@ importers:
|
|||
specifier: ^1.4.0
|
||||
version: 1.4.0(@types/node@20.11.20)(jsdom@20.0.2)(sass@1.56.1)
|
||||
|
||||
packages/connectors/connector-dingtalk-web:
|
||||
dependencies:
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^3.0.0
|
||||
version: link:../../toolkit/connector-kit
|
||||
'@silverhand/essentials':
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.1
|
||||
dayjs:
|
||||
specifier: ^1.10.5
|
||||
version: 1.11.6
|
||||
got:
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
iconv-lite:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.3
|
||||
snakecase-keys:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
devDependencies:
|
||||
'@rollup/plugin-commonjs':
|
||||
specifier: ^25.0.7
|
||||
version: 25.0.7(rollup@4.14.3)
|
||||
'@rollup/plugin-json':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(rollup@4.14.3)
|
||||
'@rollup/plugin-node-resolve':
|
||||
specifier: ^15.2.3
|
||||
version: 15.2.3(rollup@4.14.3)
|
||||
'@rollup/plugin-typescript':
|
||||
specifier: ^11.1.6
|
||||
version: 11.1.6(rollup@4.14.3)(tslib@2.6.2)(typescript@5.3.3)
|
||||
'@shopify/jest-koa-mocks':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
'@silverhand/eslint-config':
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.3.3)
|
||||
'@silverhand/ts-config':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(typescript@5.3.3)
|
||||
'@types/node':
|
||||
specifier: ^20.11.20
|
||||
version: 20.12.7
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(vitest@1.4.0(@types/node@20.12.7)(jsdom@20.0.2)(sass@1.56.1))
|
||||
eslint:
|
||||
specifier: ^8.56.0
|
||||
version: 8.57.0
|
||||
lint-staged:
|
||||
specifier: ^15.0.2
|
||||
version: 15.0.2
|
||||
nock:
|
||||
specifier: ^13.3.1
|
||||
version: 13.3.1
|
||||
prettier:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
rollup:
|
||||
specifier: ^4.12.0
|
||||
version: 4.14.3
|
||||
rollup-plugin-output-size:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(rollup@4.14.3)
|
||||
supertest:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
vitest:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(@types/node@20.12.7)(jsdom@20.0.2)(sass@1.56.1)
|
||||
|
||||
packages/connectors/connector-discord:
|
||||
dependencies:
|
||||
'@logto/connector-kit':
|
||||
|
@ -15602,7 +15684,7 @@ snapshots:
|
|||
deepmerge: 4.3.1
|
||||
is-builtin-module: 3.2.1
|
||||
is-module: 1.0.0
|
||||
resolve: 1.22.2
|
||||
resolve: 1.22.8
|
||||
optionalDependencies:
|
||||
rollup: 4.14.3
|
||||
|
||||
|
|
Loading…
Reference in a new issue