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

feat(connector): add slack connector (#7021)

This commit is contained in:
wangsijie 2025-02-19 10:13:04 +08:00 committed by GitHub
parent 5086f4bd23
commit e4eeccd36e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 522 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/connector-slack": minor
---
add slack social connector

View file

@ -0,0 +1,59 @@
# Slack connector
The official Logto connector for Slack social sign-in.
**Table of contents**
- [Slack connector](#slack-connector)
- [Get started](#get-started)
- [Set up Slack App](#set-up-slack-app)
- [Configure your connector](#configure-your-connector)
- [Config types](#config-types)
- [Test Slack connector](#test-slack-connector)
- [Reference](#reference)
## Get started
The Slack connector enables end-users to sign in to your application using their own Slack accounts via the Slack OAuth 2.0 authentication protocol.
## Set up Slack App
Go to the [Slack API: Applications](https://api.slack.com/apps) and sign in with your Slack account. If you dont have an account, you can register for one.
Then, create an app.
**Step 1:** Find `Client ID` and `Client Secret`.
You can find the `Client ID` and `Client Secret` on the **"Basic Information"** section.
**Step 2:** Set up redirect URLs.
Go to the **"OAuth & Permissions"** section, you can find the **"Redirect URLs"** form.
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.
You can refer to the [Slack API documentation](https://api.slack.com/authentication/sign-in-with-slack) for more details.
## 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 OpenID scopes. If not provided, the default scope is `openid profile`.
### Config types
| Name | Type |
| ------------ | ------ |
| clientId | string |
| clientSecret | string |
| scope | string |
## Test Slack connector
That's it! The Slack connector should now be available for end-users to sign in with their Slack 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
- [Slack API: Sign in with Slack](https://api.slack.com/authentication/sign-in-with-slack)

View file

@ -0,0 +1 @@
<svg enable-background="new 0 0 2447.6 2452.5" viewBox="0 0 2447.6 2452.5" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill-rule="evenodd"><path d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z" fill="#36c5f0"/><path d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z" fill="#2eb67d"/><path d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z" fill="#ecb22e"/><path d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0" fill="#e01e5a"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,70 @@
{
"name": "@logto/connector-slack",
"version": "0.0.0",
"description": "Slack 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,52 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
export const authorizationEndpoint = 'https://slack.com/openid/connect/authorize';
export const defaultScope = 'openid profile email';
export const accessTokenEndpoint = 'https://slack.com/api/openid.connect.token';
export const defaultMetadata: ConnectorMetadata = {
id: 'slack-universal',
target: 'slack',
platform: ConnectorPlatform.Universal,
name: {
en: 'Slack',
'zh-CN': 'Slack',
'tr-TR': 'Slack',
ko: 'Slack',
},
logo: './logo.svg',
logoDark: null,
description: {
en: 'Slack is a team communication platform for real-time conversation and information sharing.',
'zh-CN': 'Slack 是一个团队沟通平台,用于实时对话和信息共享。',
'tr-TR':
'Slack, gerçek zamanlı sohbet ve bilgi paylaşımı için bir takım iletişim platformudur.',
ko: 'Slack은 실시간 대화와 정보 공유를 위한 팀 통신 플랫폼입니다.',
},
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,94 @@
import nock from 'nock';
import { accessTokenEndpoint, authorizationEndpoint } 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 redirectUri = 'http://localhost:3000/callback';
const getSession = vi.fn().mockResolvedValue({
redirectUri,
});
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,
connectorId: 'some_connector_id',
connectorFactoryId: 'some_connector_factory_id',
jti: 'some_jti',
headers: {},
},
setSession
);
expect(setSession).toHaveBeenCalledWith({
redirectUri,
});
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?response_type=code&client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=openid+profile+email&state=some_state`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpoint).post('').reply(200, {
ok: true,
access_token: 'access_token',
token_type: 'token_type',
id_token: 'id_token',
});
const { access_token, id_token } = await getAccessToken(mockedConfig, 'code', redirectUri);
expect(access_token).toEqual('access_token');
expect(id_token).toEqual('id_token');
});
});
describe('getUserInfo', () => {
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get valid SocialUserInfo', async () => {
nock(accessTokenEndpoint).post('').query(true).reply(200, {
ok: true,
access_token: 'access_token',
token_type: 'token_type',
id_token:
'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicGljdHVyZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vanBob2RvZS5qcGcifQ.',
});
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code', redirectUri }, getSession);
expect(socialUserInfo).toStrictEqual({
id: '1234567890',
name: 'John Doe',
image: 'https://example.com/jphodoe.jpg',
email: undefined,
rawData: {
sub: '1234567890',
name: 'John Doe',
picture: 'https://example.com/jphodoe.jpg',
},
});
});
it('throws unrecognized error', async () => {
nock(accessTokenEndpoint).post('').reply(500);
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' }, vi.fn())).rejects.toThrow();
});
});

View file

@ -0,0 +1,145 @@
import { conditional } from '@silverhand/essentials';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
parseJson,
} 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,
} from './constant.js';
import {
slackConfigGuard,
authResponseGuard,
accessTokenResponseGuard,
type SlackConfig,
userInfoResponseGuard,
} from './types.js';
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }, setSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, slackConfigGuard);
await setSession({
redirectUri,
});
const queryParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope ?? defaultScope,
state,
});
return `${authorizationEndpoint}?${queryParams.toString()}`;
};
export const getAccessToken = async (config: SlackConfig, code: string, redirectUri: string) => {
const queryParams = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
});
const response = await ky
.post(accessTokenEndpoint, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: queryParams.toString(),
timeout: defaultTimeout,
})
.json();
return accessTokenResponseGuard.parse(response);
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, slackConfigGuard);
const authResponseResult = authResponseGuard.safeParse(data);
if (!authResponseResult.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(data));
}
const { code } = authResponseResult.data;
const { redirectUri } = await getSession();
if (!redirectUri) {
throw new ConnectorError(ConnectorErrorCodes.General, {
message: 'Cannot find `redirectUri` from connector session.',
});
}
try {
const { id_token } = await getAccessToken(config, code, redirectUri);
const [, payload] = id_token.split('.');
if (!payload) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
const decodedPayload = Buffer.from(payload, 'base64').toString('utf8');
const rawData = parseJson(decodedPayload);
const userInfo = userInfoResponseGuard.parse(rawData);
const { sub, name, picture, email, email_verified } = userInfo;
return {
id: sub,
name: conditional(name),
rawData,
image: conditional(picture),
email: conditional(email_verified && email),
};
} 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 createSlackConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: slackConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
export default createSlackConnector;

View file

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

View file

@ -0,0 +1,31 @@
import { z } from 'zod';
export const slackConfigGuard = z.object({
clientId: z.string(),
clientSecret: z.string(),
scope: z.string().optional(),
});
export type SlackConfig = z.infer<typeof slackConfigGuard>;
export const userInfoResponseGuard = z.object({
sub: z.string(),
name: z.string(),
picture: z.string().optional().nullable(),
email: z.string().optional().nullable(),
email_verified: z.boolean().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({
ok: z.boolean(),
access_token: z.string(),
token_type: z.string(),
id_token: z.string(),
});

61
pnpm-lock.yaml generated
View file

@ -2276,6 +2276,67 @@ importers:
specifier: ^2.1.9
version: 2.1.9(@types/node@20.11.20)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/connectors/connector-slack:
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-smsaero:
dependencies:
'@logto/connector-kit':