├── LICENSE ├── README.md ├── linkedin.test.ts ├── linkedinStrategy.ts ├── mod.ts └── types.ts /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dashport-linkedinstrategy 2 | LinkedIn OAuth 2.0 strategy for [Dashport](https://github.com/oslabs-beta/dashport) module for Deno 3 | ``` 4 | import LinkedInStrategy from 'https://deno.land/x/dashport_linkedin/mod.ts' 5 | ``` 6 | -------------------------------------------------------------------------------- /linkedin.test.ts: -------------------------------------------------------------------------------- 1 | import LinkedInStrategy from './linkedinStrategy.ts'; 2 | import { assertEquals, assertNotEquals, assert } from "https://deno.land/std@0.87.0/testing/asserts.ts" 3 | 4 | const fakeOptions = { 5 | client_id:'788zz8dnnxjo4s', 6 | redirect_uri: 'http://localhost:3000/linkedin', 7 | response_type: 'code', 8 | scope: 'r_liteprofile%20r_emailaddress%20w_member_social', 9 | client_secret: 'FHhQQW3BaNQCFilA', 10 | grant_type: 'authorization_code', 11 | }; 12 | const fakeOakCtx = { 13 | app: {}, 14 | cookies: {}, 15 | request: { url: {} }, 16 | respond: {}, 17 | response: {redirect: (string: string)=>string}, 18 | socket: {}, 19 | state: { 20 | _dashport: { 21 | session: '' 22 | } 23 | }, 24 | assert: () => 1, 25 | send: () => 2, 26 | sendEvents: () => 3, 27 | throw: () => 4, 28 | upgrade: () => 5, 29 | params: {} 30 | }; 31 | const fakeNext = () => 1; 32 | 33 | Deno.test({ 34 | name: "LinkedIn strategy should check if a created instance of LinkedIn strategy has a name property of \"linkedIn\", and \"options\" and \"uriFromParams\" properties exist. It should also have a router method.", 35 | fn(): void{ 36 | const linkedin = new LinkedInStrategy(fakeOptions); 37 | 38 | assert(linkedin.router); 39 | assertEquals(linkedin.name, 'linkedIn') 40 | assertEquals(linkedin.options, { 41 | client_id:'788zz8dnnxjo4s', 42 | redirect_uri: 'http://localhost:3000/linkedin', 43 | response_type: 'code', 44 | scope: 'r_liteprofile%20r_emailaddress%20w_member_social', 45 | client_secret: 'FHhQQW3BaNQCFilA', 46 | grant_type: 'authorization_code' 47 | }) 48 | assertEquals(linkedin.uriFromParams, 'client_id=788zz8dnnxjo4s&redirect_uri=http://localhost:3000/linkedin&response_type=code&scope=r_liteprofile%20r_emailaddress%20w_member_social&') 49 | } 50 | }); 51 | 52 | Deno.test({ 53 | name: "LinkedIn's router method should correctly call either authorize or getAuthToken", 54 | async fn(): Promise { 55 | const linkedin = new LinkedInStrategy(fakeOptions); 56 | 57 | fakeOakCtx.request.url = {search : undefined}; 58 | assertEquals(await linkedin.router(fakeOakCtx, fakeNext), await linkedin.authorize(fakeOakCtx, fakeNext)); 59 | fakeOakCtx.request.url = {search : "?code=testing"}; 60 | assertEquals(await linkedin.router(fakeOakCtx, fakeNext), await linkedin.getAuthToken(fakeOakCtx, fakeNext)); 61 | } 62 | }); 63 | 64 | 65 | Deno.test({ 66 | name: "LinkedIn's authorize method should redirect to LinkedIn for client authorization", 67 | async fn(): Promise{ 68 | const linkedin = new LinkedInStrategy(fakeOptions); 69 | 70 | assertEquals(await linkedin.authorize(fakeOakCtx, fakeNext), fakeOakCtx.response.redirect('https://www.linkedin.com/oauth/v2/authorization?' + linkedin.uriFromParams)); 71 | } 72 | }); 73 | 74 | // Deno.test({ 75 | // name: "getAuthToken method, should get the response from LinkedIn, split string and return json data ", 76 | // async fn(): Promise { 77 | // const linkedin = new LinkedInStrategy(fakeOptions); 78 | // const returnVal: any = { 79 | // tokenData: { 80 | // access_token: undefined, 81 | // expires_in: undefined, 82 | // scope: undefined, 83 | // token_type: undefined, 84 | // id_token: undefined 85 | // }, 86 | // userInfo: { 87 | // provider: "linkedIn", 88 | // providerUserId: undefined, 89 | // displayName: undefined, 90 | // name: { familyName: undefined, givenName: undefined }, 91 | // emails: [ undefined ] 92 | // } 93 | // }; 94 | 95 | // assertEquals(await linkedin.getAuthToken(fakeOakCtx, fakeNext), returnVal) 96 | // } 97 | // }); 98 | 99 | // Deno.test({ 100 | // name: "getAuthData method should return authorization data", 101 | // async fn(): Promise { 102 | // const linkedin = new LinkedInStrategy(fakeOptions); 103 | // const returnVal: any = { 104 | // tokenData: { 105 | // access_token: undefined, 106 | // expires_in: undefined, 107 | // scope: undefined, 108 | // token_type: undefined, 109 | // id_token: undefined 110 | // }, 111 | // userInfo: { 112 | // provider: "linkedIn", 113 | // providerUserId: undefined, 114 | // displayName: undefined, 115 | // name: { familyName: undefined, givenName: undefined }, 116 | // emails: [ undefined ] 117 | // } 118 | // }; 119 | 120 | // assertEquals(await linkedin.getAuthData({tokenData: returnVal.tokenData}), returnVal); 121 | // } 122 | // }); -------------------------------------------------------------------------------- /linkedinStrategy.ts: -------------------------------------------------------------------------------- 1 | import { OakContext, Options, AuthData, TokenData } from './types.ts'; 2 | /** 3 | * 4 | * Creates an instance of `LinkedInStrategy`. 5 | * 6 | * 7 | * * Options: 8 | * 9 | * - client_id: string Required 10 | * - client_secret: string Required 11 | * - redirect_uri: string Required 12 | * 13 | */ 14 | export default class LinkedInStrategy { 15 | name: string = 'linkedIn' 16 | options: Options; 17 | uriFromParams: string; 18 | /** 19 | * @constructor 20 | * @param {Object} options 21 | * @api public 22 | */ 23 | constructor (options: Options) { 24 | if (!options.client_id || !options.redirect_uri || !options.response_type || !options.scope || !options.client_secret) { 25 | throw new Error('ERROR in LinkedInStrategy constructor: Missing required arguments'); 26 | } 27 | 28 | this.options = options; 29 | const paramArray: string[][] = Object.entries(options); 30 | let paramString: string = ''; 31 | 32 | for (let i = 0; i < paramArray.length; i++) { 33 | let [key, value] = paramArray[i]; 34 | 35 | if (key === 'client_secret' || key === 'grant_type') continue; 36 | 37 | paramString += (key + '='); 38 | 39 | if (i < paramArray.length - 1) paramString += (value + '&'); 40 | else paramString += value; 41 | } 42 | 43 | this.uriFromParams = paramString; 44 | } 45 | 46 | async router(ctx: OakContext, next: Function) { 47 | if (!ctx.request.url.search) return await this.authorize(ctx, next); 48 | if (ctx.request.url.search.slice(1, 5) === 'code') return this.getAuthToken(ctx, next); 49 | } 50 | 51 | async authorize(ctx: OakContext, next: Function ) { 52 | return await ctx.response.redirect('https://www.linkedin.com/oauth/v2/authorization?' + this.uriFromParams); 53 | } 54 | 55 | async getAuthToken(ctx: OakContext, next: Function){ 56 | const OGURI: string = ctx.request.url.search; 57 | 58 | if (OGURI.includes('error')) { 59 | return new Error('ERROR in getAuthToken: Received an error from auth token code request.'); 60 | } 61 | 62 | let URI1: string[] = OGURI.split('='); 63 | const URI2: string[] = URI1[1].split('&'); 64 | const code: string = this.parseCode(URI2[0]); 65 | const options: object = { 66 | method: 'POST', 67 | headers: { "Content-Type": "x-www-form-urlencoded"}, 68 | body: JSON.stringify({ 69 | grant_type: this.options.grant_type, 70 | client_id: this.options.client_id, 71 | client_secret: this.options.client_secret, 72 | code: code, 73 | redirect_uri: this.options.redirect_uri 74 | }) 75 | } 76 | 77 | try { 78 | let data: any = await fetch(`https://www.linkedin.com/oauth/v2/accessToken?grant_type=${this.options.grant_type}&redirect_uri=${this.options.redirect_uri}&client_id=${this.options.client_id}&client_secret=${this.options.client_secret}&code=${code}`) 79 | data = await data.json(); 80 | 81 | return this.getAuthData(data); 82 | } catch(err) { 83 | return new Error(`ERROR in getAuthToken: Unable to obtain token - ${err}`); 84 | } 85 | } 86 | 87 | 88 | async getAuthData(parsed: TokenData){ 89 | const authData: AuthData = { 90 | tokenData: { 91 | access_token: parsed.access_token, 92 | expires_in: parsed.expires_in, 93 | }, 94 | userInfo: { 95 | provider: '', 96 | providerUserId: '' 97 | } 98 | }; 99 | const options: any = { 100 | headers: { 'Authorization': 'Bearer '+ parsed.access_token } 101 | }; 102 | 103 | try { 104 | let data: any = await fetch(`https://api.linkedin.com/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~:playableStreams))&oauth2_access_token=${parsed.access_token}`); 105 | let emailData: any = await fetch(`https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))&oauth2_access_token=${parsed.access_token}`) 106 | data = await data.json(); 107 | emailData = await emailData.json() 108 | 109 | authData.userInfo = { 110 | provider: this.name, 111 | providerUserId: data.id, 112 | displayName: data.firstName.localized.en_US + ' ' + data.lastName.localized.en_US, 113 | emails: [emailData.elements[0]['handle~'].emailAddress] 114 | }; 115 | 116 | return authData; 117 | } catch(err) { 118 | return new Error(`ERROR in getAuthData: Unable to obtain auth data - ${err}`); 119 | } 120 | } 121 | 122 | parseCode(encodedCode: string): string { 123 | const replacements: { [name: string] : string } = { 124 | "%24": "$", 125 | "%26": "&", 126 | "%2B": "+", 127 | "%2C": ",", 128 | "%2F": "/", 129 | "%3A": ":", 130 | "%3B": ";", 131 | "%3D": "=", 132 | "%3F": "?", 133 | "%40": "@" 134 | } 135 | 136 | const toReplaceArray: string[] = Object.keys(replacements); 137 | 138 | for(let i = 0; i < toReplaceArray.length; i++) { 139 | while (encodedCode.includes(toReplaceArray[i])) { 140 | encodedCode = encodedCode.replace(toReplaceArray[i], replacements[toReplaceArray[i]]); 141 | } 142 | } 143 | 144 | return encodedCode; 145 | } 146 | } -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import LinkedInStrategy from './linkedinStrategy.ts'; 2 | export default LinkedInStrategy; 3 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Should contain the same properties and methods defined by Oak 3 | * https://github.com/oakserver/oak 4 | */ 5 | export interface OakContext { 6 | app: any; 7 | cookies: any; 8 | request: any; 9 | respond: any; 10 | response: any; 11 | socket: any; 12 | state: any; 13 | assert: Function; 14 | send: Function; 15 | sendEvents: Function; 16 | throw: Function; 17 | upgrade: Function; 18 | params: any; 19 | locals?: any; 20 | } 21 | 22 | /** 23 | * Different OAuths will return different user information in different 24 | * structures. Dashport strategies should break down and reconstruct the user 25 | * info into the standardized UserProfile below 26 | */ 27 | export interface UserProfile { 28 | // the provider the user is authenticated with 29 | provider: string; 30 | // the unique id a user has with that specific provider 31 | providerUserId: string; 32 | // the display name or username for this specific user 33 | displayName?: string; 34 | name?: { 35 | familyName?: string; 36 | givenName?: string; 37 | middleName?: string; 38 | }; 39 | emails?: Array; 40 | } 41 | 42 | /** 43 | * 44 | * client_id: string identifies client to service provider - Required 45 | * - client_secret: string Required 46 | * - redirect_uri: string Required 47 | * - state: string Required 48 | * - response_type: string O 49 | * - scope: string 50 | * 51 | * Options that should be specified by the developer when adding 52 | */ 53 | export interface Options { 54 | client_id: string; 55 | redirect_uri: string; 56 | response_type?: string; 57 | scope?: string; 58 | client_secret: string; 59 | access_type?: string; 60 | state?: string; 61 | included_granted_scopes?: string; 62 | login_hint?: string; 63 | prompt?: string; 64 | grant_type?: string; 65 | allow_signup?: string; 66 | code?: string; 67 | } 68 | 69 | export interface TokenData { 70 | access_token: string; 71 | expires_in?: number; 72 | scope?: string; 73 | token_type?: string; 74 | id_token?: string; 75 | } 76 | 77 | export interface AuthData { 78 | tokenData: TokenData; 79 | userInfo?: UserProfile; 80 | } 81 | 82 | 83 | --------------------------------------------------------------------------------