mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(connector): add Xiaomi social login connector (#6905)
* feat(connector): add Xiaomi social login connector * chore: update README and pnpm lock * chore: update changeset * fix(connector): fix connector-xiaomi test fail & enhance error handling * refactor(connector): remove unnecessary logs and code --------- Co-authored-by: Charles Zhao <charleszhao@silverhand.io>
This commit is contained in:
parent
659ec5c298
commit
3fa2b796e6
11 changed files with 739 additions and 1 deletions
5
.changeset/rotten-lizards-buy.md
Normal file
5
.changeset/rotten-lizards-buy.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/connector-xiaomi": minor
|
||||
---
|
||||
|
||||
add Xiaomi social connector
|
69
packages/connectors/connector-xiaomi/README.md
Normal file
69
packages/connectors/connector-xiaomi/README.md
Normal file
|
@ -0,0 +1,69 @@
|
|||
# Xiaomi social connector
|
||||
|
||||
The official Logto connector for Xiaomi social sign-in. [中文文档](https://github.com/logto-io/logto/tree/master/packages/connectors/connector-xiaomi/README.zh-CN.md)
|
||||
|
||||
**Table of contents**
|
||||
|
||||
- [Xiaomi social connector](#xiaomi-social-connector)
|
||||
- [Get started](#get-started)
|
||||
- [Configure Xiaomi OAuth application](#configure-xiaomi-oauth-application)
|
||||
- [Scopes description](#scopes-description)
|
||||
- [Test Xiaomi connector](#test-xiaomi-connector)
|
||||
- [References](#references)
|
||||
|
||||
## Get started
|
||||
|
||||
1. Create a developer account at [Xiaomi Open Platform](https://dev.mi.com/)
|
||||
2. Visit [Xiaomi Account Service](https://dev.mi.com/passport/oauth2/applist)
|
||||
3. Create a new application if you don't have one
|
||||
|
||||
## Configure Xiaomi OAuth application
|
||||
|
||||
1. Visit [Xiaomi Account Service](https://dev.mi.com/passport/oauth2/applist)
|
||||
2. Configure OAuth settings:
|
||||
- Open the application you want to use for login, click on "Callback URL" (if you haven't edited the callback URL, it will display as "Enabled")
|
||||
- Add authorization callback URL: `${your_logto_origin}/callback/${connector_id}`
|
||||
- `connector_id` can be found on the top of the connector details page in Logto Console
|
||||
3. Get `AppID` and `AppSecret` from the application details page
|
||||
4. Fill in the `clientId` and `clientSecret` fields in Logto Console with the values from step 3
|
||||
5. Optional configuration:
|
||||
- `skipConfirm`: Whether to skip the Xiaomi authorization confirmation page when user is already logged in to Xiaomi account, defaults to false
|
||||
|
||||
## Scopes description
|
||||
|
||||
By default, the connector requests the following scope:
|
||||
|
||||
- `1`: Read user profile
|
||||
|
||||
Available scopes:
|
||||
|
||||
| Scope Value | Description | API Interface |
|
||||
|-------------|-------------|---------------|
|
||||
| 1 | Get user profile | user/profile |
|
||||
| 3 | Get user open_id | user/openIdV2 |
|
||||
| 1000 | Get Xiaomi router info | Mi Router |
|
||||
| 1001 | Access all Xiaomi router info | Mi Router |
|
||||
| 2001 | Access Xiaomi cloud calendar | Mi Cloud |
|
||||
| 2002 | Access Xiaomi cloud alarm | Mi Cloud |
|
||||
| 6000 | Use Mi Home smart home service | Mi Home |
|
||||
| 6002 | Add third-party devices to Mi Home | Mi Home |
|
||||
| 6003 | Alexa control Xiaomi devices | Mi Home |
|
||||
| 6004 | Third-party service access to Xiaomi devices | Mi Home |
|
||||
| 7000 | Follow Yellow Pages service account | Mi Cloud |
|
||||
| 11000 | Get Xiaomi cloud photos | Mi Cloud |
|
||||
| 12001 | Save app data to Mi Cloud | Mi Cloud |
|
||||
| 12005 | Use health ECG service | Health |
|
||||
| 16000 | Get Mi Wallet passes | app/get_pass |
|
||||
| 20000 | Enable XiaoAI voice service | XiaoAI |
|
||||
| 40000 | Enable cloud AI service | Internal Use |
|
||||
|
||||
Multiple scopes can be configured by separating them with spaces, e.g.: `1 3 6000`.
|
||||
|
||||
## Test Xiaomi connector
|
||||
|
||||
That's it. Don't forget to [Enable social sign-in](https://docs.logto.io/connectors/social-connectors#enable-social-sign-in) in the sign-in experience.
|
||||
|
||||
## References
|
||||
|
||||
- [Xiaomi OAuth 2.0 Documentation](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1708)
|
||||
- [Xiaomi Get User Profile Documentation](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1517)
|
71
packages/connectors/connector-xiaomi/README.zh-CN.md
Normal file
71
packages/connectors/connector-xiaomi/README.zh-CN.md
Normal file
|
@ -0,0 +1,71 @@
|
|||
# 小米社交连接器
|
||||
|
||||
小米社交登录 Logto 官方连接器 [中文文档](#小米社交连接器)
|
||||
|
||||
**目录**
|
||||
|
||||
- [小米社交连接器](#小米社交连接器)
|
||||
- [开始上手](#开始上手)
|
||||
- [配置小米 OAuth 应用](#配置小米-oauth-应用)
|
||||
- [权限范围说明](#权限范围说明)
|
||||
- [测试小米连接器](#测试小米连接器)
|
||||
- [参考](#参考)
|
||||
|
||||
小米是一家全球知名的科技公司,提供包括智能手机、智能家居等在内的多种产品和服务。本连接器可以帮助终端用户使用小米账号登录你的应用。
|
||||
|
||||
## 开始上手
|
||||
|
||||
1. 在[小米开放平台](https://dev.mi.com/)创建开发者账号
|
||||
2. 访问[小米帐号服务](https://dev.mi.com/passport/oauth2/applist)
|
||||
3. 创建一个新应用(如果还没有)
|
||||
|
||||
## 配置小米 OAuth 应用
|
||||
|
||||
1. 访问[小米帐号服务](https://dev.mi.com/passport/oauth2/applist)
|
||||
2. 配置 OAuth 设置:
|
||||
- 打开要用于登录的应用,点击「回调地址」(如果没有编辑过回调地址,会显示为「启用」)
|
||||
- 添加授权回调地址: `${your_logto_origin}/callback/${connector_id}`
|
||||
- `connector_id` 可以在 Logto 管理控制台连接器详情页顶部找到
|
||||
3. 从应用详情页获取 `AppID` 和 `AppSecret`
|
||||
4. 将第 3 步获取的值填入 Logto 管理控制台的 `clientId` 和 `clientSecret` 字段
|
||||
5. 可选配置:
|
||||
- `skipConfirm`: 在用户已登录小米账号的情况下,是否跳过小米授权确认页面,默认为 false
|
||||
|
||||
## 权限范围说明
|
||||
|
||||
默认情况下,连接器请求以下权限:
|
||||
|
||||
- `1`: 读取用户资料
|
||||
|
||||
可配置的权限范围:
|
||||
|
||||
| 权限值 | 描述 | API 接口 |
|
||||
|-------|------|----------|
|
||||
| 1 | 获取用户资料 | user/profile |
|
||||
| 3 | 获取用户 open_id | user/openIdV2 |
|
||||
| 1000 | 获取小米路由器信息 | 路由器 |
|
||||
| 1001 | 访问所有小米路由器信息 | 路由器 |
|
||||
| 2001 | 访问小米云日历 | 小米云 |
|
||||
| 2002 | 访问小米云闹钟 | 小米云 |
|
||||
| 6000 | 使用米家智能家居服务 | 米家 |
|
||||
| 6002 | 添加第三方设备到米家 | 米家 |
|
||||
| 6003 | Alexa 控制小米设备 | 米家 |
|
||||
| 6004 | 第三方服务访问小米设备 | 米家 |
|
||||
| 7000 | 关注黄页服务号 | 小米云 |
|
||||
| 11000 | 获取小米云相册 | 小米云 |
|
||||
| 12001 | 保存应用数据到小米云 | 小米云 |
|
||||
| 12005 | 使用健康心电图服务 | 健康 |
|
||||
| 16000 | 获取小米卡包卡券 | app/get_pass |
|
||||
| 20000 | 启用小爱智能语音服务 | 小爱 |
|
||||
| 40000 | 启用云端 AI 服务 | 内部使用 |
|
||||
|
||||
可以通过空格分隔配置多个权限范围,例如: `1 3 6000`。
|
||||
|
||||
## 测试小米连接器
|
||||
|
||||
大功告成!别忘了在[登录体验](https://docs.logto.io/zh-CN/connectors/social-connectors#enable-social-sign-in)中启用该连接器。
|
||||
|
||||
## 参考
|
||||
|
||||
- [小米 OAuth 2.0 文档](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1708)
|
||||
- [小米获取用户信息文档](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1517)
|
22
packages/connectors/connector-xiaomi/logo.svg
Normal file
22
packages/connectors/connector-xiaomi/logo.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!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="512px" height="512px" viewBox="-200.008 -199.727 512 512"
|
||||
enable-background="new -200.008 -199.727 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path id="path-1" fill="#FF6900" d="M258.626-146.231c-48.304-48.118-117.759-53.496-202.634-53.496
|
||||
c-84.982,0-154.542,5.44-202.826,53.688c-48.277,48.228-53.174,117.676-53.174,202.561c0,84.899,4.897,154.368,53.194,202.613
|
||||
c48.281,48.255,117.833,53.139,202.806,53.139c84.974,0,154.514-4.884,202.795-53.139
|
||||
c48.294-48.254,53.205-117.714,53.205-202.613C311.992-28.472,307.028-97.995,258.626-146.231L258.626-146.231z" />
|
||||
</g>
|
||||
<g>
|
||||
<path id="path-2" fill="#FFFFFF" d="M204.546-41.122c1.759,0,3.223,1.417,3.223,3.161v189.386
|
||||
c0,1.715-1.464,3.139-3.223,3.139H163.05c-1.781,0-3.228-1.424-3.228-3.139V-37.961c0-1.743,1.446-3.161,3.228-3.161H204.546z
|
||||
M24.468-41.122c31.303,0,64.033,1.435,80.176,17.589c15.871,15.897,17.59,47.549,17.656,78.286v96.671
|
||||
c0,1.715-1.446,3.139-3.219,3.139h-41.49c-1.777,0-3.229-1.424-3.229-3.139V53.09c-0.044-17.167-1.031-34.81-9.884-43.692
|
||||
c-7.62-7.641-21.839-9.391-36.625-9.754h-75.21c-1.764,0-3.208,1.419-3.208,3.136v148.645c0,1.715-1.462,3.139-3.237,3.139
|
||||
h-41.516c-1.774,0-3.201-1.424-3.201-3.139V-37.961c0-1.743,1.426-3.161,3.201-3.161H24.468z M33.755,34.305
|
||||
c1.766,0,3.201,1.413,3.201,3.143v113.977c0,1.715-1.436,3.139-3.201,3.139H-9.829c-1.792,0-3.228-1.424-3.228-3.139V37.448
|
||||
c0-1.73,1.436-3.143,3.228-3.143H33.755z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
69
packages/connectors/connector-xiaomi/package.json
Normal file
69
packages/connectors/connector-xiaomi/package.json
Normal file
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"name": "@logto/connector-xiaomi",
|
||||
"version": "1.0.0",
|
||||
"description": "Xiaomi web connector implementation.",
|
||||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"dependencies": {
|
||||
"@logto/connector-kit": "workspace:^4.0.0",
|
||||
"@silverhand/essentials": "^2.9.1",
|
||||
"ky": "^1.2.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"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",
|
||||
"check": "tsc --noEmit",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"lint:fix": "eslint --ext .ts src --fix",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": "^2.1.8",
|
||||
"eslint": "^8.56.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"nock": "14.0.0-beta.15",
|
||||
"prettier": "^3.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
59
packages/connectors/connector-xiaomi/src/constant.ts
Normal file
59
packages/connectors/connector-xiaomi/src/constant.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import type { ConnectorMetadata } from '@logto/connector-kit';
|
||||
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
|
||||
|
||||
export const authorizationEndpoint = 'https://account.xiaomi.com/oauth2/authorize';
|
||||
export const accessTokenEndpoint = 'https://account.xiaomi.com/oauth2/token';
|
||||
export const userInfoEndpoint = 'https://open.account.xiaomi.com/user/profile';
|
||||
|
||||
// Default scope is read user profile
|
||||
export const defaultScope = '1';
|
||||
|
||||
export const defaultMetadata: ConnectorMetadata = {
|
||||
id: 'xiaomi-universal',
|
||||
target: 'xiaomi',
|
||||
platform: ConnectorPlatform.Universal,
|
||||
name: {
|
||||
en: 'Xiaomi',
|
||||
'zh-CN': '小米',
|
||||
},
|
||||
logo: './logo.svg',
|
||||
logoDark: null,
|
||||
description: {
|
||||
en: 'Xiaomi is a Chinese electronics company.',
|
||||
'zh-CN': '小米是一家中国的电子产品公司。',
|
||||
},
|
||||
readme: './README.md',
|
||||
formItems: [
|
||||
{
|
||||
key: 'clientId',
|
||||
type: ConnectorConfigFormItemType.Text,
|
||||
label: 'Client ID',
|
||||
required: true,
|
||||
placeholder: '<client-id>',
|
||||
},
|
||||
{
|
||||
key: 'clientSecret',
|
||||
type: ConnectorConfigFormItemType.Text,
|
||||
label: 'Client Secret',
|
||||
required: true,
|
||||
placeholder: '<client-secret>',
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
type: ConnectorConfigFormItemType.Text,
|
||||
label: 'Scope',
|
||||
required: false,
|
||||
placeholder: '<scope>',
|
||||
description: 'The scope determines permissions granted by the user.',
|
||||
},
|
||||
{
|
||||
key: 'skipConfirm',
|
||||
type: ConnectorConfigFormItemType.Switch,
|
||||
label: 'Skip Auth Confirm Page',
|
||||
required: false,
|
||||
description: 'Skip the Xiaomi auth confirm page when the user is already logged in.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const defaultTimeout = 5000;
|
133
packages/connectors/connector-xiaomi/src/index.test.ts
Normal file
133
packages/connectors/connector-xiaomi/src/index.test.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
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, mockedAccessTokenResponse, mockedUserInfoResponse } from './mock.js';
|
||||
|
||||
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
|
||||
|
||||
describe('getAuthorizationUri', () => {
|
||||
afterEach(() => {
|
||||
vi.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: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&state=some_state&scope=1&skip_confirm=true`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it('should get an accessToken by exchanging with code', async () => {
|
||||
nock(accessTokenEndpoint)
|
||||
.post('')
|
||||
.reply(200, `&&&START&&&${JSON.stringify(mockedAccessTokenResponse)}`);
|
||||
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' }, '');
|
||||
expect(accessToken).toEqual('access_token');
|
||||
});
|
||||
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
|
||||
nock(accessTokenEndpoint)
|
||||
.post('')
|
||||
.reply(200, '&&&START&&&{"error":96010,"error_description":"invalid redirect uri"}');
|
||||
await expect(getAccessToken(mockedConfig, { code: 'code' }, '')).rejects.toStrictEqual(
|
||||
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
beforeEach(() => {
|
||||
nock(accessTokenEndpoint)
|
||||
.post('')
|
||||
.reply(200, `&&&START&&&${JSON.stringify(mockedAccessTokenResponse)}`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get valid SocialUserInfo', async () => {
|
||||
nock(userInfoEndpoint).get('').query(true).reply(200, mockedUserInfoResponse);
|
||||
const connector = await createConnector({ getConfig });
|
||||
const socialUserInfo = await connector.getUserInfo(
|
||||
{
|
||||
code: 'valid_code',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
},
|
||||
vi.fn()
|
||||
);
|
||||
expect(socialUserInfo).toStrictEqual({
|
||||
id: 'union_id',
|
||||
avatar: 'https://avatar.example.com/user.jpg',
|
||||
name: 'Test User',
|
||||
rawData: mockedUserInfoResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SocialAccessTokenInvalid error if remote response code is 403', async () => {
|
||||
// Mock the userinfo endpoint to return 403
|
||||
nock(userInfoEndpoint).get('').query(true).reply(403, {
|
||||
// eslint-disable-next-line unicorn/numeric-separators-style
|
||||
code: 96008,
|
||||
description: 'token invalid or expired',
|
||||
result: 'error',
|
||||
});
|
||||
|
||||
const connector = await createConnector({ getConfig });
|
||||
await expect(
|
||||
connector.getUserInfo(
|
||||
{
|
||||
code: 'some_code',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
).rejects.toThrow(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
|
||||
});
|
||||
|
||||
it('throws General error if remote response code is 401', async () => {
|
||||
const errorResponse = {
|
||||
// eslint-disable-next-line unicorn/numeric-separators-style
|
||||
code: 96012,
|
||||
description: 'server rejected auth request',
|
||||
result: 'error',
|
||||
};
|
||||
nock(userInfoEndpoint).get('').query(true).reply(401, JSON.stringify(errorResponse));
|
||||
|
||||
const connector = await createConnector({ getConfig });
|
||||
await expect(
|
||||
connector.getUserInfo(
|
||||
{
|
||||
code: 'some_code',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
).rejects.toThrow(
|
||||
new ConnectorError(ConnectorErrorCodes.General, {
|
||||
code: errorResponse.code,
|
||||
description: errorResponse.description,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
181
packages/connectors/connector-xiaomi/src/index.ts
Normal file
181
packages/connectors/connector-xiaomi/src/index.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { assert, conditional } from '@silverhand/essentials';
|
||||
|
||||
import {
|
||||
ConnectorError,
|
||||
ConnectorErrorCodes,
|
||||
validateConfig,
|
||||
ConnectorType,
|
||||
jsonGuard,
|
||||
type GetAuthorizationUri,
|
||||
type GetUserInfo,
|
||||
type SocialConnector,
|
||||
type CreateConnector,
|
||||
type GetConnectorConfig,
|
||||
} from '@logto/connector-kit';
|
||||
import ky, { HTTPError } from 'ky';
|
||||
|
||||
import {
|
||||
authorizationEndpoint,
|
||||
accessTokenEndpoint,
|
||||
userInfoEndpoint,
|
||||
defaultScope,
|
||||
defaultMetadata,
|
||||
defaultTimeout,
|
||||
} from './constant.js';
|
||||
import type { XiaomiConfig } from './types.js';
|
||||
import {
|
||||
xiaomiConfigGuard,
|
||||
accessTokenResponseGuard,
|
||||
userInfoResponseGuard,
|
||||
authorizationCallbackErrorGuard,
|
||||
authResponseGuard,
|
||||
getUserInfoErrorGuard,
|
||||
} from './types.js';
|
||||
|
||||
const authorizationCallbackHandler = async (parameterObject: unknown) => {
|
||||
const result = authResponseGuard.safeParse(parameterObject);
|
||||
|
||||
if (!result.success) {
|
||||
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
|
||||
|
||||
if (!parsedError.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
|
||||
}
|
||||
|
||||
const { error, error_description } = parsedError.data;
|
||||
|
||||
throw new ConnectorError(ConnectorErrorCodes.General, {
|
||||
error,
|
||||
errorDescription: error_description,
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getAuthorizationUri =
|
||||
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
|
||||
async ({ state, redirectUri }) => {
|
||||
const config = await getConfig(defaultMetadata.id);
|
||||
validateConfig(config, xiaomiConfigGuard);
|
||||
|
||||
const queryParameters = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
scope: config.scope ?? defaultScope,
|
||||
skip_confirm: String(config.skipConfirm ?? false),
|
||||
});
|
||||
|
||||
return `${authorizationEndpoint}?${queryParameters.toString()}`;
|
||||
};
|
||||
|
||||
export const getAccessToken = async (
|
||||
config: XiaomiConfig,
|
||||
codeObject: { code: string },
|
||||
redirectUri: string
|
||||
) => {
|
||||
const { code } = codeObject;
|
||||
const { clientId: client_id, clientSecret: client_secret } = config;
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
client_id,
|
||||
client_secret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const httpResponse = await ky
|
||||
.post(accessTokenEndpoint, {
|
||||
body: formData,
|
||||
timeout: defaultTimeout,
|
||||
})
|
||||
.text();
|
||||
|
||||
const jsonResponse = jsonGuard.parse(JSON.parse(httpResponse.replace('&&&START&&&', '')));
|
||||
|
||||
const result = accessTokenResponseGuard.safeParse(jsonResponse);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid);
|
||||
}
|
||||
|
||||
const { access_token: accessToken } = result.data;
|
||||
|
||||
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
|
||||
|
||||
return { accessToken };
|
||||
};
|
||||
|
||||
const getUserInfo =
|
||||
(getConfig: GetConnectorConfig): GetUserInfo =>
|
||||
async (data) => {
|
||||
const { code, redirectUri } = await authorizationCallbackHandler(data);
|
||||
const config = await getConfig(defaultMetadata.id);
|
||||
validateConfig(config, xiaomiConfigGuard);
|
||||
const { accessToken } = await getAccessToken(config, { code }, redirectUri);
|
||||
|
||||
try {
|
||||
const response = await ky
|
||||
.get(userInfoEndpoint, {
|
||||
searchParams: {
|
||||
clientId: config.clientId,
|
||||
token: accessToken,
|
||||
},
|
||||
timeout: defaultTimeout,
|
||||
})
|
||||
.json();
|
||||
|
||||
const userInfoResult = userInfoResponseGuard.safeParse(response);
|
||||
|
||||
if (!userInfoResult.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse);
|
||||
}
|
||||
|
||||
const {
|
||||
data: { miliaoNick, unionId, miliaoIcon },
|
||||
} = userInfoResult.data;
|
||||
|
||||
return {
|
||||
id: unionId,
|
||||
avatar: conditional(miliaoIcon),
|
||||
name: conditional(miliaoNick),
|
||||
rawData: jsonGuard.parse(response),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
const errorBody: unknown = await error.response.json();
|
||||
const parsedError = getUserInfoErrorGuard.safeParse(errorBody);
|
||||
|
||||
if (!parsedError.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.General, parsedError.error);
|
||||
}
|
||||
|
||||
const { code, description } = parsedError.data;
|
||||
if (error.response.status === 403) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
|
||||
}
|
||||
|
||||
throw new ConnectorError(ConnectorErrorCodes.General, {
|
||||
code,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const createXiaomiConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Social,
|
||||
configGuard: xiaomiConfigGuard,
|
||||
getAuthorizationUri: getAuthorizationUri(getConfig),
|
||||
getUserInfo: getUserInfo(getConfig),
|
||||
};
|
||||
};
|
||||
|
||||
export default createXiaomiConnector;
|
26
packages/connectors/connector-xiaomi/src/mock.ts
Normal file
26
packages/connectors/connector-xiaomi/src/mock.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
export const mockedConfig = {
|
||||
clientId: '<client-id>',
|
||||
clientSecret: '<client-secret>',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
skipConfirm: true,
|
||||
};
|
||||
|
||||
export const mockedAccessTokenResponse = {
|
||||
access_token: 'access_token',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'refresh_token',
|
||||
scope: '1',
|
||||
openId: 'openId',
|
||||
union_id: 'union_id',
|
||||
};
|
||||
|
||||
export const mockedUserInfoResponse = {
|
||||
result: 'ok',
|
||||
code: 0,
|
||||
description: 'no error',
|
||||
data: {
|
||||
miliaoNick: 'Test User',
|
||||
unionId: 'union_id',
|
||||
miliaoIcon: 'https://avatar.example.com/user.jpg',
|
||||
},
|
||||
};
|
48
packages/connectors/connector-xiaomi/src/types.ts
Normal file
48
packages/connectors/connector-xiaomi/src/types.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const xiaomiConfigGuard = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
scope: z.string().optional(),
|
||||
redirectUri: z.string().optional(),
|
||||
skipConfirm: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type XiaomiConfig = z.infer<typeof xiaomiConfigGuard>;
|
||||
|
||||
export const accessTokenResponseGuard = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string(),
|
||||
openId: z.string(),
|
||||
union_id: z.string(),
|
||||
});
|
||||
|
||||
export type AccessTokenResponse = z.infer<typeof accessTokenResponseGuard>;
|
||||
|
||||
export const userInfoResponseGuard = z.object({
|
||||
result: z.string(),
|
||||
code: z.number(),
|
||||
description: z.string(),
|
||||
data: z.object({
|
||||
unionId: z.string(),
|
||||
miliaoNick: z.string().optional(),
|
||||
miliaoIcon: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
|
||||
|
||||
export const authorizationCallbackErrorGuard = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string(),
|
||||
});
|
||||
|
||||
export const authResponseGuard = z.object({ code: z.string(), redirectUri: z.string() });
|
||||
|
||||
export const getUserInfoErrorGuard = z.object({
|
||||
code: z.number(),
|
||||
description: z.string(),
|
||||
result: z.string(),
|
||||
});
|
|
@ -2739,6 +2739,61 @@ importers:
|
|||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/node@20.10.4)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
|
||||
|
||||
packages/connectors/connector-xiaomi:
|
||||
dependencies:
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^4.0.0
|
||||
version: link:../../toolkit/connector-kit
|
||||
'@silverhand/essentials':
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.2
|
||||
ky:
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
devDependencies:
|
||||
'@silverhand/eslint-config':
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3)
|
||||
'@silverhand/ts-config':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(typescript@5.5.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: ^2.1.8
|
||||
version: 2.1.8(vitest@2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8))
|
||||
eslint:
|
||||
specifier: ^8.56.0
|
||||
version: 8.57.0
|
||||
lint-staged:
|
||||
specifier: ^15.0.2
|
||||
version: 15.0.2
|
||||
nock:
|
||||
specifier: 14.0.0-beta.15
|
||||
version: 14.0.0-beta.15
|
||||
prettier:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
supertest:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
tsup:
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.5.3)(yaml@2.4.5)
|
||||
typescript:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
vitest:
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
|
||||
|
||||
packages/console:
|
||||
devDependencies:
|
||||
'@fontsource/roboto-mono':
|
||||
|
@ -16382,7 +16437,7 @@ snapshots:
|
|||
'@logto/client@2.7.2':
|
||||
dependencies:
|
||||
'@logto/js': 4.1.4
|
||||
'@silverhand/essentials': 2.9.1
|
||||
'@silverhand/essentials': 2.9.2
|
||||
camelcase-keys: 7.0.2
|
||||
jose: 5.9.6
|
||||
|
||||
|
|
Loading…
Reference in a new issue