Skip to content

Commit 726c2d8

Browse files
authored
feat: added linkedin openid connect driver (#157)
1 parent 07e4ce8 commit 726c2d8

8 files changed

+269
-0
lines changed

configure.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const AVAILABLE_PROVIDERS = [
1919
'github',
2020
'google',
2121
'linkedin',
22+
'linkedinOpenidConnect',
2223
'spotify',
2324
'twitter',
2425
]

examples/app.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async function run() {
3030
'./twitter.js',
3131
'./google.js',
3232
'./linkedin.js',
33+
'./linkedin_openid_connect.js',
3334
'./facebook.js',
3435
'./spotify.js',
3536
],

examples/config/ally.ts

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const allyConfig = defineConfig({
2121
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
2222
callbackUrl: `http://localhost:${process.env.PORT}/linkedin/callback`,
2323
}),
24+
linkedinOpenidConnect: services.linkedinOpenidConnect({
25+
clientId: process.env.LINKEDIN_CLIENT_ID!,
26+
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
27+
callbackUrl: `http://localhost:${process.env.PORT}/linkedin/callback`,
28+
}),
2429
twitter: services.twitter({
2530
clientId: process.env.TWITTER_API_KEY!,
2631
clientSecret: process.env.TWITTER_APP_SECRET!,

examples/linkedin_openid_connect.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import router from '@adonisjs/core/services/router'
2+
3+
router.get('linkedin', async ({ response }) => {
4+
return response.send('<a href="/linkedin/redirect"> Login with linkedin </a>')
5+
})
6+
7+
router.get('/linkedin/redirect', async ({ ally }) => {
8+
return ally.use('linkedin').redirect()
9+
})
10+
11+
router.get('/linkedin/callback', async ({ ally }) => {
12+
try {
13+
const linkedin = ally.use('linkedinOpenidConnect')
14+
if (linkedin.accessDenied()) {
15+
return 'Access was denied'
16+
}
17+
18+
if (linkedin.stateMisMatch()) {
19+
return 'Request expired. Retry again'
20+
}
21+
22+
if (linkedin.hasError()) {
23+
return linkedin.getError()
24+
}
25+
26+
const user = await linkedin.user()
27+
return user
28+
} catch (error) {
29+
console.log({ error: error.response })
30+
throw error
31+
}
32+
})

src/define_config.ts

+11
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import type { TwitterDriver } from './drivers/twitter.js'
1818
import type { DiscordDriver } from './drivers/discord.js'
1919
import type { FacebookDriver } from './drivers/facebook.js'
2020
import type { LinkedInDriver } from './drivers/linked_in.js'
21+
import type { LinkedInOpenidConnectDriver } from './drivers/linked_in_openid_connect.js'
2122
import type {
2223
GoogleDriverConfig,
2324
GithubDriverConfig,
2425
SpotifyDriverConfig,
2526
DiscordDriverConfig,
2627
TwitterDriverConfig,
2728
LinkedInDriverConfig,
29+
LinkedInOpenidConnectDriverConfig,
2830
FacebookDriverConfig,
2931
AllyManagerDriverFactory,
3032
} from './types.js'
@@ -79,6 +81,9 @@ export const services: {
7981
github: (config: GithubDriverConfig) => ConfigProvider<(ctx: HttpContext) => GithubDriver>
8082
google: (config: GoogleDriverConfig) => ConfigProvider<(ctx: HttpContext) => GoogleDriver>
8183
linkedin: (config: LinkedInDriverConfig) => ConfigProvider<(ctx: HttpContext) => LinkedInDriver>
84+
linkedinOpenidConnect: (
85+
config: LinkedInOpenidConnectDriverConfig
86+
) => ConfigProvider<(ctx: HttpContext) => LinkedInOpenidConnectDriver>
8287
spotify: (config: SpotifyDriverConfig) => ConfigProvider<(ctx: HttpContext) => SpotifyDriver>
8388
twitter: (config: TwitterDriverConfig) => ConfigProvider<(ctx: HttpContext) => TwitterDriver>
8489
} = {
@@ -112,6 +117,12 @@ export const services: {
112117
return (ctx) => new LinkedInDriver(ctx, config)
113118
})
114119
},
120+
linkedinOpenidConnect(config) {
121+
return configProvider.create(async () => {
122+
const { LinkedInOpenidConnectDriver } = await import('./drivers/linked_in_openid_connect.js')
123+
return (ctx) => new LinkedInOpenidConnectDriver(ctx, config)
124+
})
125+
},
115126
spotify(config) {
116127
return configProvider.create(async () => {
117128
const { SpotifyDriver } = await import('./drivers/spotify.js')
+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Oauth2Driver } from '../abstract_drivers/oauth2.js'
2+
import type { HttpContext } from '@adonisjs/core/http'
3+
import type {
4+
ApiRequestContract,
5+
LinkedInOpenidConnectAccessToken,
6+
LinkedInOpenidConnectDriverConfig,
7+
LinkedInOpenidConnectScopes,
8+
RedirectRequestContract,
9+
} from '@adonisjs/ally/types'
10+
import type { HttpClient } from '@poppinss/oauth-client'
11+
12+
/**
13+
* LinkedIn openid connect driver to login user via LinkedIn using openid connect requirements
14+
*/
15+
export class LinkedInOpenidConnectDriver extends Oauth2Driver<
16+
LinkedInOpenidConnectAccessToken,
17+
LinkedInOpenidConnectScopes
18+
> {
19+
protected authorizeUrl = 'https://www.linkedin.com/oauth/v2/authorization'
20+
protected accessTokenUrl = 'https://www.linkedin.com/oauth/v2/accessToken'
21+
protected userInfoUrl = 'https://api.linkedin.com/v2/userinfo'
22+
23+
/**
24+
* The param name for the authorization code
25+
*/
26+
protected codeParamName = 'code'
27+
28+
/**
29+
* The param name for the error
30+
*/
31+
protected errorParamName = 'error'
32+
33+
/**
34+
* Cookie name for storing the "linkedin_openid_connect_oauth_state"
35+
*/
36+
protected stateCookieName = 'linkedin_openid_connect_oauth_state'
37+
38+
/**
39+
* Parameter name to be used for sending and receiving the state
40+
* from linkedin
41+
*/
42+
protected stateParamName = 'state'
43+
44+
/**
45+
* Parameter name for defining the scopes
46+
*/
47+
protected scopeParamName = 'scope'
48+
49+
/**
50+
* Scopes separator
51+
*/
52+
protected scopesSeparator = ' '
53+
54+
constructor(
55+
ctx: HttpContext,
56+
public config: LinkedInOpenidConnectDriverConfig
57+
) {
58+
super(ctx, config)
59+
/**
60+
* Extremely important to call the following method to clear the
61+
* state set by the redirect request.
62+
*
63+
* DO NOT REMOVE THE FOLLOWING LINE
64+
*/
65+
this.loadState()
66+
}
67+
68+
/**
69+
* Configuring the redirect request with defaults
70+
*/
71+
protected configureRedirectRequest(
72+
request: RedirectRequestContract<LinkedInOpenidConnectScopes>
73+
) {
74+
/**
75+
* Define user defined scopes or the default one's
76+
*/
77+
request.scopes(this.config.scopes || ['openid', 'profile', 'email'])
78+
79+
/**
80+
* Set "response_type" param
81+
*/
82+
request.param('response_type', 'code')
83+
}
84+
85+
/**
86+
* Returns the HTTP request with the authorization header set
87+
*/
88+
protected getAuthenticatedRequest(url: string, token: string): HttpClient {
89+
const request = this.httpClient(url)
90+
request.header('Authorization', `Bearer ${token}`)
91+
request.header('Accept', 'application/json')
92+
request.parseAs('json')
93+
return request
94+
}
95+
96+
/**
97+
* Fetches the user info from the LinkedIn API
98+
*/
99+
protected async getUserInfo(token: string, callback?: (request: ApiRequestContract) => void) {
100+
let url = this.config.userInfoUrl || this.userInfoUrl
101+
const request = this.getAuthenticatedRequest(url, token)
102+
103+
if (typeof callback === 'function') {
104+
callback(request)
105+
}
106+
107+
const body = await request.get()
108+
const emailVerificationState: 'verified' | 'unverified' = body.email_verified
109+
? 'verified'
110+
: 'unverified'
111+
112+
return {
113+
id: body.sub,
114+
nickName: body.given_name,
115+
name: body.family_name,
116+
avatarUrl: body.picture,
117+
email: body.email,
118+
emailVerificationState,
119+
original: body,
120+
}
121+
}
122+
123+
/**
124+
* Find if the current error code is for access denied
125+
*/
126+
accessDenied(): boolean {
127+
const error = this.getError()
128+
if (!error) {
129+
return false
130+
}
131+
132+
return error === 'user_cancelled_login' || error === 'user_cancelled_authorize'
133+
}
134+
135+
/**
136+
* Returns details for the authorized user
137+
*/
138+
async user(callback?: (request: ApiRequestContract) => void) {
139+
const accessToken = await this.accessToken(callback)
140+
const userInfo = await this.getUserInfo(accessToken.token, callback)
141+
142+
return {
143+
...userInfo,
144+
token: { ...accessToken },
145+
}
146+
}
147+
148+
/**
149+
* Finds the user by the access token
150+
*/
151+
async userFromToken(token: string, callback?: (request: ApiRequestContract) => void) {
152+
const user = await this.getUserInfo(token, callback)
153+
154+
return {
155+
...user,
156+
token: { token, type: 'bearer' as const },
157+
}
158+
}
159+
}

src/types.ts

+40
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,46 @@ export type LinkedInDriverConfig = Oauth2ClientConfig & {
433433
scopes?: LiteralStringUnion<LinkedInScopes>[]
434434
}
435435

436+
/**
437+
* ----------------------------------------
438+
* LinkedIn openid connect driver
439+
* ----------------------------------------
440+
*/
441+
442+
/**
443+
* Shape of the LinkedIn openid connect access token
444+
*/
445+
export type LinkedInOpenidConnectAccessToken = {
446+
token: string
447+
type: 'bearer'
448+
expiresIn: number
449+
expiresAt: Exclude<Oauth2AccessToken['expiresAt'], undefined>
450+
}
451+
452+
/**
453+
* Config accepted by the linkedIn openid connect driver. Most of the options can be
454+
* overwritten at runtime
455+
* https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#authenticating-members
456+
*/
457+
export type LinkedInOpenidConnectScopes = 'openid' | 'profile' | 'email'
458+
459+
/**
460+
* The configuration accepted by the driver implementation.
461+
*/
462+
export type LinkedInOpenidConnectDriverConfig = {
463+
clientId: string
464+
clientSecret: string
465+
callbackUrl: string
466+
authorizeUrl?: string
467+
accessTokenUrl?: string
468+
userInfoUrl?: string
469+
470+
/**
471+
* Can be configured at runtime
472+
*/
473+
scopes?: LiteralStringUnion<LinkedInOpenidConnectScopes>[]
474+
}
475+
436476
/**
437477
* ----------------------------------------
438478
* Facebook driver

tests/define_config.spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { FacebookDriver } from '../src/drivers/facebook.js'
2121
import { LinkedInDriver } from '../src/drivers/linked_in.js'
2222
import { SpotifyDriver } from '../src/drivers/spotify.js'
2323
import { TwitterDriver } from '../src/drivers/twitter.js'
24+
import { LinkedInOpenidConnectDriver } from '../src/drivers/linked_in_openid_connect.js'
2425

2526
const BASE_URL = new URL('./', import.meta.url)
2627
const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService
@@ -142,6 +143,25 @@ test.group('Config services', () => {
142143
expectTypeOf(ally.use('linkedin')).toMatchTypeOf<LinkedInDriver>()
143144
})
144145

146+
test('configure linkedin openid connect driver', async ({ assert, expectTypeOf }) => {
147+
const managerConfig = await defineConfig({
148+
linkedinOpenidConnect: services.linkedinOpenidConnect({
149+
clientId: '',
150+
clientSecret: '',
151+
callbackUrl: '',
152+
scopes: ['email', 'profile'],
153+
}),
154+
}).resolver(app)
155+
156+
const ctx = new HttpContextFactory().create()
157+
const ally = new AllyManager(managerConfig, ctx)
158+
159+
assert.instanceOf(ally.use('linkedinOpenidConnect'), LinkedInOpenidConnectDriver)
160+
assert.strictEqual(ally.use('linkedinOpenidConnect'), ally.use('linkedinOpenidConnect'))
161+
expectTypeOf(ally.use).parameters.toEqualTypeOf<['linkedinOpenidConnect']>()
162+
expectTypeOf(ally.use('linkedinOpenidConnect')).toMatchTypeOf<LinkedInOpenidConnectDriver>()
163+
})
164+
145165
test('configure spotify driver', async ({ assert, expectTypeOf }) => {
146166
const managerConfig = await defineConfig({
147167
spotify: services.spotify({

0 commit comments

Comments
 (0)