Skip to content

Commit 8e6db27

Browse files
authored
feat(connector): add linkedin connector (#7032)
1 parent 98f45de commit 8e6db27

File tree

11 files changed

+538
-0
lines changed

11 files changed

+538
-0
lines changed

.changeset/thin-paws-yell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@logto/connector-linkedin": minor
3+
---
4+
5+
add LinkedIn social connector
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# LinkedIn connector
2+
3+
The official Logto connector for LinkedIn social sign-in.
4+
5+
**Table of contents**
6+
- [LinkedIn connector](#linkedin-connector)
7+
- [Get started](#get-started)
8+
- [Setup a LinkedIn app](#setup-a-linkedin-app)
9+
- [Configure your connector](#configure-your-connector)
10+
- [Config types](#config-types)
11+
- [Test LinkedIn connector](#test-linkedin-connector)
12+
- [Reference](#reference)
13+
14+
## Get started
15+
16+
The LinkedIn connector enables end-users to sign in to your application using their own LinkedIn accounts via the LinkedIn OAuth 2.0 authentication protocol.
17+
18+
## Setup a LinkedIn app
19+
20+
Go to the [LinkedIn Developers](https://www.linkedin.com/developers) and sign in with your LinkedIn account. If you don’t have an account, you can register for one.
21+
22+
Then, create an app.
23+
24+
**Step 1:** Fill in the App Details.
25+
26+
Complete the form and create the app.
27+
28+
**Step 2:** Setup callback URLs.
29+
30+
Go to App details page and find "Auth" tab, "OAuth 2.0" section and find the field "Authorized redirect URLs for your app".
31+
32+
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.
33+
34+
**Step 3:** Add the product.
35+
36+
Go to "Products" tab and add the product "Sign In with LinkedIn using OpenID Connect".
37+
38+
## Configure your connector
39+
40+
In your Logto connector configuration, fill out the following fields with the values obtained from your App's "Auth" tab, "Application credentials" section:
41+
42+
- **clientId:** Your App's Client ID.
43+
- **clientSecret:** Your App's Primary Client Secret.
44+
45+
`scope` is a space-delimited list of OIDC scopes. If not provided, the default scope is `openid profile email`.
46+
47+
### Config types
48+
49+
| Name | Type |
50+
| ------------ | ------ |
51+
| clientId | string |
52+
| clientSecret | string |
53+
| scope | string |
54+
55+
## Test LinkedIn connector
56+
57+
That's it! The LinkedIn connector should now be available for end-users to sign in with their LinkedIn 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/).
58+
59+
## Reference
60+
61+
- [Sign in with LinkedIn](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin)
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"name": "@logto/connector-linkedin",
3+
"version": "0.0.0",
4+
"description": "LinkedIn web connector implementation.",
5+
"author": "Silverhand Inc. <[email protected]>",
6+
"dependencies": {
7+
"@logto/connector-kit": "workspace:^4.0.0",
8+
"@silverhand/essentials": "^2.9.1",
9+
"ky": "^1.2.3",
10+
"query-string": "^9.0.0",
11+
"snakecase-keys": "^8.0.1",
12+
"zod": "^3.23.8"
13+
},
14+
"main": "./lib/index.js",
15+
"module": "./lib/index.js",
16+
"exports": "./lib/index.js",
17+
"license": "MPL-2.0",
18+
"type": "module",
19+
"files": [
20+
"lib",
21+
"docs",
22+
"logo.svg",
23+
"logo-dark.svg"
24+
],
25+
"scripts": {
26+
"precommit": "lint-staged",
27+
"check": "tsc --noEmit",
28+
"build": "tsup",
29+
"dev": "tsup --watch",
30+
"lint": "eslint --ext .ts src",
31+
"lint:report": "pnpm lint --format json --output-file report.json",
32+
"test": "vitest src",
33+
"test:ci": "pnpm run test --silent --coverage",
34+
"prepublishOnly": "pnpm build"
35+
},
36+
"engines": {
37+
"node": "^20.9.0"
38+
},
39+
"eslintConfig": {
40+
"extends": "@silverhand",
41+
"settings": {
42+
"import/core-modules": [
43+
"@silverhand/essentials",
44+
"got",
45+
"nock",
46+
"snakecase-keys",
47+
"zod"
48+
]
49+
}
50+
},
51+
"prettier": "@silverhand/eslint-config/.prettierrc",
52+
"publishConfig": {
53+
"access": "public"
54+
},
55+
"devDependencies": {
56+
"@silverhand/eslint-config": "6.0.1",
57+
"@silverhand/ts-config": "6.0.0",
58+
"@types/node": "^20.11.20",
59+
"@types/supertest": "^6.0.2",
60+
"@vitest/coverage-v8": "^2.1.8",
61+
"eslint": "^8.56.0",
62+
"lint-staged": "^15.0.2",
63+
"nock": "14.0.0-beta.15",
64+
"prettier": "^3.0.0",
65+
"supertest": "^7.0.0",
66+
"tsup": "^8.3.0",
67+
"typescript": "^5.5.3",
68+
"vitest": "^2.1.8"
69+
}
70+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ConnectorMetadata } from '@logto/connector-kit';
2+
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
3+
4+
// See https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin
5+
export const authorizationEndpoint = 'https://www.linkedin.com/oauth/v2/authorization';
6+
export const defaultScope = 'openid profile email';
7+
export const accessTokenEndpoint = 'https://www.linkedin.com/oauth/v2/accessToken';
8+
export const userInfoEndpoint = 'https://api.linkedin.com/v2/userinfo';
9+
10+
export const defaultMetadata: ConnectorMetadata = {
11+
id: 'linkedin-universal',
12+
target: 'linkedin',
13+
platform: ConnectorPlatform.Universal,
14+
name: {
15+
en: 'LinkedIn',
16+
'zh-CN': 'LinkedIn',
17+
'tr-TR': 'LinkedIn',
18+
ko: 'LinkedIn',
19+
},
20+
logo: './logo.svg',
21+
logoDark: null,
22+
description: {
23+
en: 'LinkedIn is a social media platform for professional networking and information sharing.',
24+
'zh-CN': 'LinkedIn是一个职业社交和信息分享的社交媒体平台。',
25+
'tr-TR':
26+
'LinkedIn, profesyonel ağ oluşturma ve bilgi paylaşma için bir sosyal medya platformudur.',
27+
ko: 'LinkedIn은 전문 네트워킹과 정보 공유를 위한 소셜 미디어 플랫폼입니다.',
28+
},
29+
readme: './README.md',
30+
formItems: [
31+
{
32+
key: 'clientId',
33+
type: ConnectorConfigFormItemType.Text,
34+
label: 'Client ID',
35+
required: true,
36+
},
37+
{
38+
key: 'clientSecret',
39+
type: ConnectorConfigFormItemType.Text,
40+
label: 'Client Secret',
41+
required: true,
42+
},
43+
{
44+
key: 'scope',
45+
type: ConnectorConfigFormItemType.Text,
46+
label: 'Scope',
47+
required: false,
48+
description:
49+
"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.",
50+
},
51+
],
52+
};
53+
54+
export const defaultTimeout = 5000;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import nock from 'nock';
2+
3+
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js';
4+
import createConnector, { getAccessToken } from './index.js';
5+
import { mockedConfig } from './mock.js';
6+
7+
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
8+
const setSession = vi.fn();
9+
const getSession = vi.fn().mockResolvedValue({
10+
redirectUri: 'http://localhost:3000/callback',
11+
});
12+
13+
describe('getAuthorizationUri', () => {
14+
afterEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
18+
it('should get a valid uri by redirectUri and state', async () => {
19+
const connector = await createConnector({ getConfig });
20+
const authorizationUri = await connector.getAuthorizationUri(
21+
{
22+
state: 'some_state',
23+
redirectUri: 'http://localhost:3000/callback',
24+
connectorId: 'some_connector_id',
25+
connectorFactoryId: 'some_connector_factory_id',
26+
jti: 'some_jti',
27+
headers: {},
28+
},
29+
setSession
30+
);
31+
expect(setSession).toHaveBeenCalledWith({
32+
redirectUri: 'http://localhost:3000/callback',
33+
});
34+
expect(authorizationUri).toEqual(
35+
`${authorizationEndpoint}?response_type=code&client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=openid+profile+email&state=some_state`
36+
);
37+
});
38+
});
39+
40+
describe('getAccessToken', () => {
41+
afterEach(() => {
42+
nock.cleanAll();
43+
vi.clearAllMocks();
44+
});
45+
46+
it('should get an accessToken by exchanging with code', async () => {
47+
nock(accessTokenEndpoint).post('').reply(200, {
48+
access_token: 'access_token',
49+
});
50+
const { access_token } = await getAccessToken(mockedConfig, 'code', 'redirectUri');
51+
expect(access_token).toEqual('access_token');
52+
});
53+
});
54+
55+
describe('getUserInfo', () => {
56+
beforeEach(() => {
57+
nock(accessTokenEndpoint).post('').query(true).reply(200, {
58+
access_token: 'access_token',
59+
});
60+
});
61+
62+
afterEach(() => {
63+
nock.cleanAll();
64+
vi.clearAllMocks();
65+
});
66+
67+
it('should get valid SocialUserInfo', async () => {
68+
nock(userInfoEndpoint).get('').reply(200, {
69+
sub: '1',
70+
name: 'monalisa',
71+
72+
picture: 'https://example.com/picture.jpg',
73+
});
74+
const connector = await createConnector({ getConfig });
75+
const socialUserInfo = await connector.getUserInfo(
76+
{ code: 'code', redirectUri: 'http://localhost:3000/callback' },
77+
getSession
78+
);
79+
expect(socialUserInfo).toStrictEqual({
80+
id: '1',
81+
name: 'monalisa',
82+
83+
picture: 'https://example.com/picture.jpg',
84+
rawData: {
85+
sub: '1',
86+
name: 'monalisa',
87+
88+
picture: 'https://example.com/picture.jpg',
89+
},
90+
});
91+
});
92+
93+
it('throws unrecognized error', async () => {
94+
nock(userInfoEndpoint).get('').reply(500);
95+
const connector = await createConnector({ getConfig });
96+
await expect(connector.getUserInfo({ code: 'code' }, vi.fn())).rejects.toThrow();
97+
});
98+
});

0 commit comments

Comments
 (0)