diff --git a/configure.ts b/configure.ts
index 3854d21..e780b10 100644
--- a/configure.ts
+++ b/configure.ts
@@ -19,6 +19,7 @@ const AVAILABLE_PROVIDERS = [
'github',
'google',
'linkedin',
+ 'linkedinOpenidConnect',
'spotify',
'twitter',
]
diff --git a/examples/app.ts b/examples/app.ts
index e1a05a2..25e220c 100644
--- a/examples/app.ts
+++ b/examples/app.ts
@@ -30,6 +30,7 @@ async function run() {
'./twitter.js',
'./google.js',
'./linkedin.js',
+ './linkedin_openid_connect.js',
'./facebook.js',
'./spotify.js',
],
diff --git a/examples/config/ally.ts b/examples/config/ally.ts
index 4cc09ee..22d9940 100644
--- a/examples/config/ally.ts
+++ b/examples/config/ally.ts
@@ -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!,
diff --git a/examples/linkedin_openid_connect.ts b/examples/linkedin_openid_connect.ts
new file mode 100644
index 0000000..b4cc27a
--- /dev/null
+++ b/examples/linkedin_openid_connect.ts
@@ -0,0 +1,32 @@
+import router from '@adonisjs/core/services/router'
+
+router.get('linkedin', async ({ response }) => {
+ return response.send(' Login with linkedin ')
+})
+
+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
+ }
+})
diff --git a/src/define_config.ts b/src/define_config.ts
index 7ae93cf..d895ca1 100644
--- a/src/define_config.ts
+++ b/src/define_config.ts
@@ -18,6 +18,7 @@ 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,
@@ -25,6 +26,7 @@ import type {
DiscordDriverConfig,
TwitterDriverConfig,
LinkedInDriverConfig,
+ LinkedInOpenidConnectDriverConfig,
FacebookDriverConfig,
AllyManagerDriverFactory,
} from './types.js'
@@ -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>
} = {
@@ -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')
diff --git a/src/drivers/linked_in_openid_connect.ts b/src/drivers/linked_in_openid_connect.ts
new file mode 100644
index 0000000..b194ac9
--- /dev/null
+++ b/src/drivers/linked_in_openid_connect.ts
@@ -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
+ ) {
+ /**
+ * 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 },
+ }
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index 79089a4..2d3b8ea 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -433,6 +433,46 @@ export type LinkedInDriverConfig = Oauth2ClientConfig & {
scopes?: LiteralStringUnion[]
}
+/**
+ * ----------------------------------------
+ * LinkedIn openid connect driver
+ * ----------------------------------------
+ */
+
+/**
+ * Shape of the LinkedIn openid connect access token
+ */
+export type LinkedInOpenidConnectAccessToken = {
+ token: string
+ type: 'bearer'
+ expiresIn: number
+ expiresAt: Exclude
+}
+
+/**
+ * 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[]
+}
+
/**
* ----------------------------------------
* Facebook driver
diff --git a/tests/define_config.spec.ts b/tests/define_config.spec.ts
index 3862c12..74fd7e4 100644
--- a/tests/define_config.spec.ts
+++ b/tests/define_config.spec.ts
@@ -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
@@ -142,6 +143,25 @@ test.group('Config services', () => {
expectTypeOf(ally.use('linkedin')).toMatchTypeOf()
})
+ 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()
+ })
+
test('configure spotify driver', async ({ assert, expectTypeOf }) => {
const managerConfig = await defineConfig({
spotify: services.spotify({