Skip to content

Commit 8e6db27

Browse files
authoredFeb 20, 2025··
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

+5
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
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)
Loading
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. <contact@silverhand.io>",
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+
}
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;
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+
email: 'monalisa@example.com',
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+
email: 'monalisa@example.com',
83+
picture: 'https://example.com/picture.jpg',
84+
rawData: {
85+
sub: '1',
86+
name: 'monalisa',
87+
email: 'monalisa@example.com',
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+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { conditional } from '@silverhand/essentials';
2+
3+
import {
4+
ConnectorError,
5+
ConnectorErrorCodes,
6+
validateConfig,
7+
ConnectorType,
8+
jsonGuard,
9+
} from '@logto/connector-kit';
10+
import type {
11+
GetAuthorizationUri,
12+
GetUserInfo,
13+
SocialConnector,
14+
CreateConnector,
15+
GetConnectorConfig,
16+
} from '@logto/connector-kit';
17+
import ky, { HTTPError } from 'ky';
18+
19+
import {
20+
authorizationEndpoint,
21+
accessTokenEndpoint,
22+
defaultMetadata,
23+
defaultTimeout,
24+
defaultScope,
25+
userInfoEndpoint,
26+
} from './constant.js';
27+
import type { LinkedInConfig } from './types.js';
28+
import {
29+
linkedInConfigGuard,
30+
userInfoResponseGuard,
31+
authResponseGuard,
32+
accessTokenResponseGuard,
33+
} from './types.js';
34+
35+
const getAuthorizationUri =
36+
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
37+
async ({ state, redirectUri }, setSession) => {
38+
const config = await getConfig(defaultMetadata.id);
39+
validateConfig(config, linkedInConfigGuard);
40+
41+
await setSession({ redirectUri });
42+
43+
const queryParams = new URLSearchParams({
44+
response_type: 'code',
45+
client_id: config.clientId,
46+
redirect_uri: redirectUri,
47+
scope: config.scope ?? defaultScope,
48+
state,
49+
});
50+
51+
return `${authorizationEndpoint}?${queryParams.toString()}`;
52+
};
53+
54+
export const getAccessToken = async (config: LinkedInConfig, code: string, redirectUri: string) => {
55+
const response = await ky
56+
.post(accessTokenEndpoint, {
57+
headers: {
58+
'Content-Type': 'application/x-www-form-urlencoded',
59+
},
60+
body: new URLSearchParams({
61+
grant_type: 'authorization_code',
62+
code,
63+
redirect_uri: redirectUri,
64+
client_id: config.clientId,
65+
client_secret: config.clientSecret,
66+
}).toString(),
67+
timeout: defaultTimeout,
68+
})
69+
.json();
70+
71+
return accessTokenResponseGuard.parse(response);
72+
};
73+
74+
const getUserInfo =
75+
(getConfig: GetConnectorConfig): GetUserInfo =>
76+
async (data, getSession) => {
77+
const config = await getConfig(defaultMetadata.id);
78+
validateConfig(config, linkedInConfigGuard);
79+
80+
const authResponseResult = authResponseGuard.safeParse(data);
81+
82+
if (!authResponseResult.success) {
83+
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(data));
84+
}
85+
86+
const { code } = authResponseResult.data;
87+
const { redirectUri } = await getSession();
88+
89+
if (!redirectUri) {
90+
throw new ConnectorError(ConnectorErrorCodes.General, {
91+
message: 'Cannot find `redirectUri` from connector session.',
92+
});
93+
}
94+
95+
try {
96+
const { access_token } = await getAccessToken(config, code, redirectUri);
97+
98+
const userInfo = await ky
99+
.get(userInfoEndpoint, {
100+
headers: {
101+
Authorization: `Bearer ${access_token}`,
102+
},
103+
timeout: defaultTimeout,
104+
})
105+
.json();
106+
const userInfoResult = userInfoResponseGuard.safeParse(userInfo);
107+
108+
if (!userInfoResult.success) {
109+
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userInfoResult.error);
110+
}
111+
112+
const { sub, name, email, picture } = userInfoResult.data;
113+
114+
return {
115+
id: sub,
116+
name: conditional(name),
117+
email: conditional(email),
118+
picture: conditional(picture),
119+
rawData: jsonGuard.parse(userInfo),
120+
};
121+
} catch (error: unknown) {
122+
if (error instanceof HTTPError) {
123+
const { status, body: rawBody } = error.response;
124+
125+
if (status === 401) {
126+
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
127+
}
128+
129+
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
130+
}
131+
132+
throw error;
133+
}
134+
};
135+
136+
const createLinkedInConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
137+
return {
138+
metadata: defaultMetadata,
139+
type: ConnectorType.Social,
140+
configGuard: linkedInConfigGuard,
141+
getAuthorizationUri: getAuthorizationUri(getConfig),
142+
getUserInfo: getUserInfo(getConfig),
143+
};
144+
};
145+
146+
export default createLinkedInConnector;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const mockedConfig = {
2+
clientId: '<client-id>',
3+
clientSecret: '<client-secret>',
4+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { z } from 'zod';
2+
3+
export const linkedInConfigGuard = z.object({
4+
clientId: z.string(),
5+
clientSecret: z.string(),
6+
scope: z.string().optional(),
7+
});
8+
9+
export type LinkedInConfig = z.infer<typeof linkedInConfigGuard>;
10+
11+
export const userInfoResponseGuard = z.object({
12+
sub: z.string(),
13+
name: z.string().nullish(),
14+
email: z.string().nullish(),
15+
picture: z.string().nullish(),
16+
});
17+
18+
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
19+
20+
export const authResponseGuard = z.object({
21+
code: z.string(),
22+
});
23+
24+
export const accessTokenResponseGuard = z.object({
25+
access_token: z.string(),
26+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import crypto from 'node:crypto';
2+
3+
export const generateCodeVerifier = () => {
4+
const buffer = crypto.randomBytes(32);
5+
return buffer.toString('base64url');
6+
};
7+
8+
export const generateCodeChallenge = (verifier: string) => {
9+
const hash = crypto.createHash('sha256');
10+
hash.update(verifier);
11+
return hash.digest('base64url');
12+
};

‎pnpm-lock.yaml

+61
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.