Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(connector): add dingtalk connector #5915

Merged
merged 10 commits into from
May 29, 2024
Merged
39 changes: 39 additions & 0 deletions packages/connectors/connector-dingtalk-web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 钉钉网页

先进企业协作与管理平台,一站式无缝办公协作,团队上下对齐目标,全面激活组织和个人。

## 开始上手

钉钉网页连接器是为桌面网页应用设计的。它采用了 OAuth 2.0 认证流程。

## 注册钉钉开发者账号

如果你还没有钉钉开发者账号,请在 [钉钉开放平台](https://open.dingtalk.com) 注册。

## 创建应用

1. 在 [钉钉开发者后台](https://open-dev.dingtalk.com/console/index) 中,点击「创建应用」
anyidea marked this conversation as resolved.
Show resolved Hide resolved
2. 选择「自建应用」,填写应用名称和基本信息,点击「创建」
3. 在左侧导航栏选择「开发配置」->「安全设置」,找到并配置「重定向 URL」 `${your_logto_origin}/callback/${connector_id}`。其中 `connector_id` 在管理控制台添加了相应的连接器之后,可以在连接器的详情页中找到
4. 在左侧导航栏选择「基础信息」->「凭证与基础信息」中可以获取「AppKey」、「AppSecret」
5. 在左侧导航栏选择「应用发布」->「版本管理与发布」,创建并发布第一个版本,以使「AppKey」、「AppSecret」生效

> ℹ️ **Note**
> 应用不发布版本,所获取的「AppKey」、「AppSecret」 均无法使用,或请求错误。

## 配置权限

1. 在「开发配置」->「权限管理」中,选择需要的权限并进行授权
2. 确认权限配置后,点击「保存」并发布应用

## 完成集成

1. 使用获取的「AppKey」和「AppSecret」完成 OAuth 2.0 认证
2. 根据需要调用钉钉开放平台的 API,实现应用功能

> ℹ️ **Note**
> 请确保在开发过程中,严格遵守钉钉开放平台的使用规范和开发指南。

## 支持

如有任何问题或需进一步帮助,请访问 [钉钉开发者文档](https://open.dingtalk.com/document/orgapp/obtain-identity-credentials) 或联系钉钉技术支持。
4 changes: 4 additions & 0 deletions packages/connectors/connector-dingtalk-web/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions packages/connectors/connector-dingtalk-web/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
59 changes: 59 additions & 0 deletions packages/connectors/connector-dingtalk-web/src/constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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
export const userInfoEndpoint = 'https://api.dingtalk.com/v1.0/contact/users/me';
anyidea marked this conversation as resolved.
Show resolved Hide resolved
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;
132 changes: 132 additions & 0 deletions packages/connectors/connector-dingtalk-web/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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');
});

it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
nock(accessTokenEndpoint).post('').reply(200, {
accessToken: '',
refreshToken: 'scope',
expires_in: 7200,
corpId: 'corpId',
});

await expect(getAccessToken('code', mockedConfig)).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
});
});

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();
});
});
});
Loading
Loading