Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added linkedin openid connect driver #157

Merged
merged 2 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading