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({