0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(connector): add x connector (#7015)

This commit is contained in:
wangsijie 2025-02-17 20:05:20 +08:00 committed by GitHub
parent 5735d9ddfc
commit ae3bda7179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 604 additions and 17 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/connector-x": minor
---
add X (Twitter) social connector

View file

@ -0,0 +1,71 @@
# X connector
The official Logto connector for X (formerly Twitter) social sign-in.
**Table of contents**
- [X connector](#x-connector)
- [Get started](#get-started)
- [Create an app in the X Developer Portal](#create-an-app-in-the-x-developer-portal)
- [Configure your connector](#configure-your-connector)
- [Config types](#config-types)
- [Test X connector](#test-x-connector)
- [Reference](#reference)
## Get started
The X connector enables end-users to sign in to your application using their own X (formerly Twitter) accounts via the X OAuth 2.0 authentication protocol.
## Create an app in the X Developer Portal
Go to the [X Developer Portal](https://developer.x.com/en/portal/projects-and-apps) and sign in with your X account. If you dont have an account, you can register for one.
Then, create an app.
**Step 1:** Navigate to the app creation section.
Once signed in, go to the "Projects & Apps" section and click on **"Create App"** (or **"New App"**, depending on the interface).
**Step 2:** Fill in the app details.
Complete the form with the following information:
- **App Name:** Provide a unique and descriptive name for your application.
- **Application Description:** (Optional) Add a brief description of what your app does.
- **Website URL:** Enter the URL of your application's homepage.
- **Callback URL / Redirect URI:** In our case, this will be `${your_logto_endpoint}/callback/${connector_id}`. e.g. `https://foo.logto.app/callback/${connector_id}`. The `connector_id` can be found on the top bar of the Logto Admin Console connector details page.
**Step 3:** Select permissions and scopes.
Choose the permissions that your app requires. For social sign-in via X, make sure you enable the necessary scopes `tweet.read` and `users.read`.
**Step 4:** Save your app.
Click **"Create"** or **"Save"** to register your app.
After creation, navigate to your apps **"Keys and tokens"** section to retrieve your **OAuth 2.0 Client ID and Client Secret**.
## Configure your connector
In your Logto connector configuration, fill out the following fields with the values obtained from your App's "Keys and tokens" page's "OAuth 2.0 Client ID and Client Secret" section:
- **clientId:** Your App's Client ID.
- **clientSecret:** Your App's Client Secret.
`scope` is a space-delimited list of [scopes](https://docs.x.com/x-api/users/user-lookup-me). If not provided, the default scope is `tweet.read users.read`.
### Config types
| Name | Type |
| ------------ | ------ |
| clientId | string |
| clientSecret | string |
| scope | string |
## Test X connector
That's it! The X connector should now be available for end-users to sign in with their X accounts. Don't forget to [Enable the connector in the sign-in experience](https://docs.logto.io/docs/recipes/configure-connectors/social-connector/enable-social-sign-in/).
## Reference
- [X Developer documentation](https://developer.x.com/en/docs)
- [X OAuth 2.0 Authorization Code Flow with PKCE](https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code)

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="100" height="100" viewBox="0 0 50 50">
<path d="M 11 4 C 7.134 4 4 7.134 4 11 L 4 39 C 4 42.866 7.134 46 11 46 L 39 46 C 42.866 46 46 42.866 46 39 L 46 11 C 46 7.134 42.866 4 39 4 L 11 4 z M 13.085938 13 L 21.023438 13 L 26.660156 21.009766 L 33.5 13 L 36 13 L 27.789062 22.613281 L 37.914062 37 L 29.978516 37 L 23.4375 27.707031 L 15.5 37 L 13 37 L 22.308594 26.103516 L 13.085938 13 z M 16.914062 15 L 31.021484 35 L 34.085938 35 L 19.978516 15 L 16.914062 15 z"></path>
</svg>

After

Width:  |  Height:  |  Size: 544 B

View file

@ -0,0 +1,70 @@
{
"name": "@logto/connector-x",
"version": "0.0.0",
"description": "X 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",
"query-string": "^9.0.0",
"snakecase-keys": "^8.0.1",
"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: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"
}
}

View file

@ -0,0 +1,53 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
export const authorizationEndpoint = 'https://twitter.com/i/oauth2/authorize';
export const defaultScope = 'tweet.read users.read';
export const accessTokenEndpoint = 'https://api.twitter.com/2/oauth2/token';
export const userInfoEndpoint = 'https://api.twitter.com/2/users/me';
export const defaultMetadata: ConnectorMetadata = {
id: 'x-universal',
target: 'x',
platform: ConnectorPlatform.Universal,
name: {
en: 'X',
'zh-CN': 'X',
'tr-TR': 'X',
ko: 'X',
},
logo: './logo.svg',
logoDark: null,
description: {
en: 'X (formerly Twitter) is a social media platform for real-time conversation and information sharing.',
'zh-CN': 'X前身为 Twitter是一个实时对话和信息分享的社交媒体平台。',
'tr-TR':
'X (eski adıyla Twitter), gerçek zamanlı sohbet ve bilgi paylaşımı için bir sosyal medya platformudur.',
ko: 'X(구 Twitter)는 실시간 대화와 정보 공유를 위한 소셜 미디어 플랫폼입니다.',
},
readme: './README.md',
formItems: [
{
key: 'clientId',
type: ConnectorConfigFormItemType.Text,
label: 'Client ID',
required: true,
},
{
key: 'clientSecret',
type: ConnectorConfigFormItemType.Text,
label: 'Client Secret',
required: true,
},
{
key: 'scope',
type: ConnectorConfigFormItemType.Text,
label: 'Scope',
required: false,
description:
"The `scope` determines permissions granted by the user's authorization. If you are not sure what to enter, do not worry, just leave it blank.",
},
],
};
export const defaultTimeout = 5000;

View file

@ -0,0 +1,114 @@
import nock from 'nock';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js';
import createConnector, { getAccessToken } from './index.js';
import { mockedConfig } from './mock.js';
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
const setSession = vi.fn();
const getSession = vi.fn().mockResolvedValue({
codeVerifier: 'codeVerifier',
});
vi.mock('./utils.js', () => ({
generateCodeVerifier: vi.fn().mockReturnValue('codeVerifier'),
generateCodeChallenge: vi.fn().mockReturnValue('codeChallenge'),
}));
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: {},
},
setSession
);
expect(setSession).toHaveBeenCalledWith({
codeVerifier: 'codeVerifier',
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?response_type=code&client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=tweet.read+users.read&state=some_state&code_challenge=codeChallenge&code_challenge_method=S256`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpoint).post('').reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
expires_in: 3600,
});
const { access_token } = await getAccessToken(
mockedConfig,
'code',
'codeVerifier',
'redirectUri'
);
expect(access_token).toEqual('access_token');
});
});
describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint).post('').query(true).reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
expires_in: 3600,
});
});
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpoint)
.get('')
.reply(200, {
data: {
id: '1',
name: 'monalisa',
},
});
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo(
{ code: 'code', redirectUri: 'http://localhost:3000/callback' },
getSession
);
expect(socialUserInfo).toStrictEqual({
id: '1',
name: 'monalisa',
rawData: {
data: {
id: '1',
name: 'monalisa',
},
},
});
});
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();
});
});

View file

@ -0,0 +1,164 @@
import { conditional } from '@silverhand/essentials';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
jsonGuard,
} from '@logto/connector-kit';
import type {
GetAuthorizationUri,
GetUserInfo,
SocialConnector,
CreateConnector,
GetConnectorConfig,
} from '@logto/connector-kit';
import ky, { HTTPError } from 'ky';
import {
authorizationEndpoint,
accessTokenEndpoint,
defaultMetadata,
defaultTimeout,
defaultScope,
userInfoEndpoint,
} from './constant.js';
import type { XConfig } from './types.js';
import {
xConfigGuard,
userInfoResponseGuard,
authResponseGuard,
accessTokenResponseGuard,
} from './types.js';
import { generateCodeVerifier, generateCodeChallenge } from './utils.js';
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }, setSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, xConfigGuard);
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
await setSession({ codeVerifier });
const queryParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope ?? defaultScope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${authorizationEndpoint}?${queryParams.toString()}`;
};
export const getAccessToken = async (
config: XConfig,
code: string,
codeVerifier: string,
redirectUri: string
) => {
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
const response = await ky
.post(accessTokenEndpoint, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier,
}).toString(),
timeout: defaultTimeout,
})
.json();
return accessTokenResponseGuard.parse(response);
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, xConfigGuard);
const authResponseResult = authResponseGuard.safeParse(data);
if (!authResponseResult.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(data));
}
const { code, redirectUri } = authResponseResult.data;
const { codeVerifier } = await getSession();
if (!codeVerifier || typeof codeVerifier !== 'string') {
throw new ConnectorError(ConnectorErrorCodes.General, {
message: 'Cannot find `codeVerifier` from connector session.',
});
}
try {
const { access_token, token_type } = await getAccessToken(
config,
code,
codeVerifier,
redirectUri
);
const userInfo = await ky
.get(userInfoEndpoint, {
headers: {
Authorization: `${token_type} ${access_token}`,
},
timeout: defaultTimeout,
})
.json();
const userInfoResult = userInfoResponseGuard.safeParse(userInfo);
if (!userInfoResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userInfoResult.error);
}
const {
data: { id, name },
} = userInfoResult.data;
return {
id,
name: conditional(name),
rawData: jsonGuard.parse(userInfo),
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { status, body: rawBody } = error.response;
if (status === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
}
};
const createXConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: xConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
export default createXConnector;

View file

@ -0,0 +1,4 @@
export const mockedConfig = {
clientId: '<client-id>',
clientSecret: '<client-secret>',
};

View file

@ -0,0 +1,30 @@
import { z } from 'zod';
export const xConfigGuard = z.object({
clientId: z.string(),
clientSecret: z.string(),
scope: z.string().optional(),
});
export type XConfig = z.infer<typeof xConfigGuard>;
export const userInfoResponseGuard = z.object({
data: z.object({
id: z.string(),
name: z.string().optional().nullable(),
}),
});
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export const authResponseGuard = z.object({
code: z.string(),
redirectUri: z.string(),
});
export const accessTokenResponseGuard = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
scope: z.string(),
});

View file

@ -0,0 +1,12 @@
import crypto from 'node:crypto';
export const generateCodeVerifier = () => {
const buffer = crypto.randomBytes(32);
return buffer.toString('base64url');
};
export const generateCodeChallenge = (verifier: string) => {
const hash = crypto.createHash('sha256');
hash.update(verifier);
return hash.digest('base64url');
};

95
pnpm-lock.yaml generated
View file

@ -2743,6 +2743,67 @@ importers:
specifier: ^2.1.9
version: 2.1.9(@types/node@20.10.4)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/connectors/connector-x:
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
query-string:
specifier: ^9.0.0
version: 9.0.0
snakecase-keys:
specifier: ^8.0.1
version: 8.0.1
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.9(vitest@2.1.9(@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.5.1)(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.9(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/connectors/connector-xiaomi:
dependencies:
'@logto/connector-kit':
@ -17651,7 +17712,7 @@ snapshots:
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
semver: 7.6.0
semver: 7.6.3
ts-api-utils: 1.3.0(typescript@5.5.3)
optionalDependencies:
typescript: 5.5.3
@ -17697,8 +17758,8 @@ snapshots:
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.4
semver: 7.6.0
minimatch: 9.0.5
semver: 7.6.3
ts-api-utils: 1.3.0(typescript@5.5.3)
optionalDependencies:
typescript: 5.5.3
@ -17714,7 +17775,7 @@ snapshots:
'@typescript-eslint/types': 7.7.0
'@typescript-eslint/typescript-estree': 7.7.0(typescript@5.5.3)
eslint: 8.57.0
semver: 7.6.0
semver: 7.6.3
transitivePeerDependencies:
- supports-color
- typescript
@ -19918,7 +19979,7 @@ snapshots:
eslint-compat-utils@0.5.0(eslint@8.57.0):
dependencies:
eslint: 8.57.0
semver: 7.6.0
semver: 7.6.3
eslint-config-prettier@9.1.0(eslint@8.57.0):
dependencies:
@ -20052,8 +20113,8 @@ snapshots:
get-tsconfig: 4.7.3
globals: 14.0.0
ignore: 5.3.1
minimatch: 9.0.4
semver: 7.6.0
minimatch: 9.0.5
semver: 7.6.3
eslint-plugin-no-use-extend-native@0.5.0:
dependencies:
@ -20114,7 +20175,7 @@ snapshots:
eslint-plugin-unicorn@52.0.0(eslint@8.57.0):
dependencies:
'@babel/helper-validator-identifier': 7.25.7
'@babel/helper-validator-identifier': 7.25.9
'@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0)
'@eslint/eslintrc': 2.1.4
ci-info: 4.0.0
@ -20129,7 +20190,7 @@ snapshots:
read-pkg-up: 7.0.1
regexp-tree: 0.1.27
regjsparser: 0.10.0
semver: 7.6.0
semver: 7.6.3
strip-indent: 3.0.0
transitivePeerDependencies:
- supports-color
@ -21460,10 +21521,10 @@ snapshots:
istanbul-lib-instrument@6.0.1:
dependencies:
'@babel/core': 7.24.9
'@babel/parser': 7.24.8
'@babel/parser': 7.26.3
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
semver: 7.6.0
semver: 7.6.3
transitivePeerDependencies:
- supports-color
@ -21475,7 +21536,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
debug: 4.3.4
debug: 4.4.0
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@ -24073,9 +24134,9 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-sorting@8.0.2(postcss@8.4.39):
postcss-sorting@8.0.2(postcss@8.5.1):
dependencies:
postcss: 8.4.39
postcss: 8.5.1
postcss-value-parser@3.3.1: {}
@ -25310,8 +25371,8 @@ snapshots:
stylelint-order@6.0.4(stylelint@15.11.0(typescript@5.5.3)):
dependencies:
postcss: 8.4.39
postcss-sorting: 8.0.2(postcss@8.4.39)
postcss: 8.5.1
postcss-sorting: 8.0.2(postcss@8.5.1)
stylelint: 15.11.0(typescript@5.5.3)
stylelint-scss@6.2.1(stylelint@15.11.0(typescript@5.5.3)):
@ -25438,7 +25499,7 @@ snapshots:
synckit@0.8.8:
dependencies:
'@pkgr/core': 0.1.1
tslib: 2.8.0
tslib: 2.8.1
table-layout@3.0.2:
dependencies: