Skip to content

Commit

Permalink
feat: added linkedin openid connect driver (#157)
Browse files Browse the repository at this point in the history
  • Loading branch information
LoicOuth authored Jan 6, 2025
1 parent 07e4ce8 commit 726c2d8
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 0 deletions.
1 change: 1 addition & 0 deletions configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const AVAILABLE_PROVIDERS = [
'github',
'google',
'linkedin',
'linkedinOpenidConnect',
'spotify',
'twitter',
]
Expand Down
1 change: 1 addition & 0 deletions examples/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ async function run() {
'./twitter.js',
'./google.js',
'./linkedin.js',
'./linkedin_openid_connect.js',
'./facebook.js',
'./spotify.js',
],
Expand Down
5 changes: 5 additions & 0 deletions examples/config/ally.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const allyConfig = defineConfig({
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
callbackUrl: `http://localhost:${process.env.PORT}/linkedin/callback`,
}),
linkedinOpenidConnect: services.linkedinOpenidConnect({
clientId: process.env.LINKEDIN_CLIENT_ID!,
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
callbackUrl: `http://localhost:${process.env.PORT}/linkedin/callback`,
}),
twitter: services.twitter({
clientId: process.env.TWITTER_API_KEY!,
clientSecret: process.env.TWITTER_APP_SECRET!,
Expand Down
32 changes: 32 additions & 0 deletions examples/linkedin_openid_connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import router from '@adonisjs/core/services/router'

router.get('linkedin', async ({ response }) => {
return response.send('<a href="/linkedin/redirect"> Login with linkedin </a>')
})

router.get('/linkedin/redirect', async ({ ally }) => {
return ally.use('linkedin').redirect()
})

router.get('/linkedin/callback', async ({ ally }) => {
try {
const linkedin = ally.use('linkedinOpenidConnect')
if (linkedin.accessDenied()) {
return 'Access was denied'
}

if (linkedin.stateMisMatch()) {
return 'Request expired. Retry again'
}

if (linkedin.hasError()) {
return linkedin.getError()
}

const user = await linkedin.user()
return user
} catch (error) {
console.log({ error: error.response })
throw error
}
})
11 changes: 11 additions & 0 deletions src/define_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import type { TwitterDriver } from './drivers/twitter.js'
import type { DiscordDriver } from './drivers/discord.js'
import type { FacebookDriver } from './drivers/facebook.js'
import type { LinkedInDriver } from './drivers/linked_in.js'
import type { LinkedInOpenidConnectDriver } from './drivers/linked_in_openid_connect.js'
import type {
GoogleDriverConfig,
GithubDriverConfig,
SpotifyDriverConfig,
DiscordDriverConfig,
TwitterDriverConfig,
LinkedInDriverConfig,
LinkedInOpenidConnectDriverConfig,
FacebookDriverConfig,
AllyManagerDriverFactory,
} from './types.js'
Expand Down Expand Up @@ -79,6 +81,9 @@ export const services: {
github: (config: GithubDriverConfig) => ConfigProvider<(ctx: HttpContext) => GithubDriver>
google: (config: GoogleDriverConfig) => ConfigProvider<(ctx: HttpContext) => GoogleDriver>
linkedin: (config: LinkedInDriverConfig) => ConfigProvider<(ctx: HttpContext) => LinkedInDriver>
linkedinOpenidConnect: (
config: LinkedInOpenidConnectDriverConfig
) => ConfigProvider<(ctx: HttpContext) => LinkedInOpenidConnectDriver>
spotify: (config: SpotifyDriverConfig) => ConfigProvider<(ctx: HttpContext) => SpotifyDriver>
twitter: (config: TwitterDriverConfig) => ConfigProvider<(ctx: HttpContext) => TwitterDriver>
} = {
Expand Down Expand Up @@ -112,6 +117,12 @@ export const services: {
return (ctx) => new LinkedInDriver(ctx, config)
})
},
linkedinOpenidConnect(config) {
return configProvider.create(async () => {
const { LinkedInOpenidConnectDriver } = await import('./drivers/linked_in_openid_connect.js')
return (ctx) => new LinkedInOpenidConnectDriver(ctx, config)
})
},
spotify(config) {
return configProvider.create(async () => {
const { SpotifyDriver } = await import('./drivers/spotify.js')
Expand Down
159 changes: 159 additions & 0 deletions src/drivers/linked_in_openid_connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Oauth2Driver } from '../abstract_drivers/oauth2.js'
import type { HttpContext } from '@adonisjs/core/http'
import type {
ApiRequestContract,
LinkedInOpenidConnectAccessToken,
LinkedInOpenidConnectDriverConfig,
LinkedInOpenidConnectScopes,
RedirectRequestContract,
} from '@adonisjs/ally/types'
import type { HttpClient } from '@poppinss/oauth-client'

/**
* LinkedIn openid connect driver to login user via LinkedIn using openid connect requirements
*/
export class LinkedInOpenidConnectDriver extends Oauth2Driver<
LinkedInOpenidConnectAccessToken,
LinkedInOpenidConnectScopes
> {
protected authorizeUrl = 'https://www.linkedin.com/oauth/v2/authorization'
protected accessTokenUrl = 'https://www.linkedin.com/oauth/v2/accessToken'
protected userInfoUrl = 'https://api.linkedin.com/v2/userinfo'

/**
* The param name for the authorization code
*/
protected codeParamName = 'code'

/**
* The param name for the error
*/
protected errorParamName = 'error'

/**
* Cookie name for storing the "linkedin_openid_connect_oauth_state"
*/
protected stateCookieName = 'linkedin_openid_connect_oauth_state'

/**
* Parameter name to be used for sending and receiving the state
* from linkedin
*/
protected stateParamName = 'state'

/**
* Parameter name for defining the scopes
*/
protected scopeParamName = 'scope'

/**
* Scopes separator
*/
protected scopesSeparator = ' '

constructor(
ctx: HttpContext,
public config: LinkedInOpenidConnectDriverConfig
) {
super(ctx, config)
/**
* Extremely important to call the following method to clear the
* state set by the redirect request.
*
* DO NOT REMOVE THE FOLLOWING LINE
*/
this.loadState()
}

/**
* Configuring the redirect request with defaults
*/
protected configureRedirectRequest(
request: RedirectRequestContract<LinkedInOpenidConnectScopes>
) {
/**
* Define user defined scopes or the default one's
*/
request.scopes(this.config.scopes || ['openid', 'profile', 'email'])

/**
* Set "response_type" param
*/
request.param('response_type', 'code')
}

/**
* Returns the HTTP request with the authorization header set
*/
protected getAuthenticatedRequest(url: string, token: string): HttpClient {
const request = this.httpClient(url)
request.header('Authorization', `Bearer ${token}`)
request.header('Accept', 'application/json')
request.parseAs('json')
return request
}

/**
* Fetches the user info from the LinkedIn API
*/
protected async getUserInfo(token: string, callback?: (request: ApiRequestContract) => void) {
let url = this.config.userInfoUrl || this.userInfoUrl
const request = this.getAuthenticatedRequest(url, token)

if (typeof callback === 'function') {
callback(request)
}

const body = await request.get()
const emailVerificationState: 'verified' | 'unverified' = body.email_verified
? 'verified'
: 'unverified'

return {
id: body.sub,
nickName: body.given_name,
name: body.family_name,
avatarUrl: body.picture,
email: body.email,
emailVerificationState,
original: body,
}
}

/**
* Find if the current error code is for access denied
*/
accessDenied(): boolean {
const error = this.getError()
if (!error) {
return false
}

return error === 'user_cancelled_login' || error === 'user_cancelled_authorize'
}

/**
* Returns details for the authorized user
*/
async user(callback?: (request: ApiRequestContract) => void) {
const accessToken = await this.accessToken(callback)
const userInfo = await this.getUserInfo(accessToken.token, callback)

return {
...userInfo,
token: { ...accessToken },
}
}

/**
* Finds the user by the access token
*/
async userFromToken(token: string, callback?: (request: ApiRequestContract) => void) {
const user = await this.getUserInfo(token, callback)

return {
...user,
token: { token, type: 'bearer' as const },
}
}
}
40 changes: 40 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,46 @@ export type LinkedInDriverConfig = Oauth2ClientConfig & {
scopes?: LiteralStringUnion<LinkedInScopes>[]
}

/**
* ----------------------------------------
* LinkedIn openid connect driver
* ----------------------------------------
*/

/**
* Shape of the LinkedIn openid connect access token
*/
export type LinkedInOpenidConnectAccessToken = {
token: string
type: 'bearer'
expiresIn: number
expiresAt: Exclude<Oauth2AccessToken['expiresAt'], undefined>
}

/**
* Config accepted by the linkedIn openid connect driver. Most of the options can be
* overwritten at runtime
* https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#authenticating-members
*/
export type LinkedInOpenidConnectScopes = 'openid' | 'profile' | 'email'

/**
* The configuration accepted by the driver implementation.
*/
export type LinkedInOpenidConnectDriverConfig = {
clientId: string
clientSecret: string
callbackUrl: string
authorizeUrl?: string
accessTokenUrl?: string
userInfoUrl?: string

/**
* Can be configured at runtime
*/
scopes?: LiteralStringUnion<LinkedInOpenidConnectScopes>[]
}

/**
* ----------------------------------------
* Facebook driver
Expand Down
20 changes: 20 additions & 0 deletions tests/define_config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { FacebookDriver } from '../src/drivers/facebook.js'
import { LinkedInDriver } from '../src/drivers/linked_in.js'
import { SpotifyDriver } from '../src/drivers/spotify.js'
import { TwitterDriver } from '../src/drivers/twitter.js'
import { LinkedInOpenidConnectDriver } from '../src/drivers/linked_in_openid_connect.js'

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

test('configure linkedin openid connect driver', async ({ assert, expectTypeOf }) => {
const managerConfig = await defineConfig({
linkedinOpenidConnect: services.linkedinOpenidConnect({
clientId: '',
clientSecret: '',
callbackUrl: '',
scopes: ['email', 'profile'],
}),
}).resolver(app)

const ctx = new HttpContextFactory().create()
const ally = new AllyManager(managerConfig, ctx)

assert.instanceOf(ally.use('linkedinOpenidConnect'), LinkedInOpenidConnectDriver)
assert.strictEqual(ally.use('linkedinOpenidConnect'), ally.use('linkedinOpenidConnect'))
expectTypeOf(ally.use).parameters.toEqualTypeOf<['linkedinOpenidConnect']>()
expectTypeOf(ally.use('linkedinOpenidConnect')).toMatchTypeOf<LinkedInOpenidConnectDriver>()
})

test('configure spotify driver', async ({ assert, expectTypeOf }) => {
const managerConfig = await defineConfig({
spotify: services.spotify({
Expand Down

0 comments on commit 726c2d8

Please sign in to comment.