From 339cf97f8ae4eeffd9d3590ab983f49e2d03f3e1 Mon Sep 17 00:00:00 2001 From: Natalie Cluer Date: Tue, 19 Sep 2023 11:27:41 -0400 Subject: [PATCH 1/2] feat: update strategy to new linkedin implementation --- example/server.js | 87 +++++++++---------- lib/oauth2.js | 180 ++++++++++------------------------------ test/basic-profile.json | 76 ----------------- test/email-address.json | 10 --- test/lite-profile.json | 76 ----------------- test/profile.json | 10 +++ test/strategy.tests.js | 157 +++++++---------------------------- 7 files changed, 128 insertions(+), 468 deletions(-) delete mode 100644 test/basic-profile.json delete mode 100644 test/email-address.json delete mode 100644 test/lite-profile.json create mode 100644 test/profile.json diff --git a/example/server.js b/example/server.js index dcfdda3c..92c7641e 100644 --- a/example/server.js +++ b/example/server.js @@ -1,13 +1,13 @@ -var express = require('express') - , passport = require('passport') - , LinkedinStrategy = require('../lib').Strategy; +var express = require('express'), + passport = require('passport'), + LinkedinStrategy = require('../lib').Strategy; // API Access link for creating client ID and secret: // https://www.linkedin.com/secure/developer var LINKEDIN_CLIENT_ID = process.env.LINKEDIN_CLIENT_ID; var LINKEDIN_CLIENT_SECRET = process.env.LINKEDIN_CLIENT_SECRET; -var CALLBACK_URL = process.env.CALLBACK_URL || 'http://localhost:3000/auth/linkedin/callback'; - +var CALLBACK_URL = + process.env.CALLBACK_URL || 'http://localhost:3000/auth/linkedin/callback'; // Passport session setup. // To support persistent login sessions, Passport needs to be able to @@ -16,41 +16,40 @@ var CALLBACK_URL = process.env.CALLBACK_URL || 'http://localhost:3000/auth/linke // the user by ID when deserializing. However, since this example does not // have a database of user records, the complete Linkedin profile is // serialized and deserialized. -passport.serializeUser(function(user, done) { +passport.serializeUser(function (user, done) { done(null, user); }); -passport.deserializeUser(function(obj, done) { +passport.deserializeUser(function (obj, done) { done(null, obj); }); - // Use the LinkedinStrategy within Passport. // Strategies in Passport require a `verify` function, which accept // credentials (in this case, an accessToken, refreshToken, and Linkedin // profile), and invoke a callback with a user object. -passport.use(new LinkedinStrategy({ - clientID: LINKEDIN_CLIENT_ID, - clientSecret: LINKEDIN_CLIENT_SECRET, - callbackURL: CALLBACK_URL, - scope: ['r_liteprofile', 'r_emailaddress'], - passReqToCallback: true - }, - function(req, accessToken, refreshToken, profile, done) { - // asynchronous verification, for effect... - req.session.accessToken = accessToken; - process.nextTick(function () { - // To keep the example simple, the user's Linkedin profile is returned to - // represent the logged-in user. In a typical application, you would want - // to associate the Linkedin account with a user record in your database, - // and return that user instead. - return done(null, profile); - }); - } -)); - - - +passport.use( + new LinkedinStrategy( + { + clientID: LINKEDIN_CLIENT_ID, + clientSecret: LINKEDIN_CLIENT_SECRET, + callbackURL: CALLBACK_URL, + scope: ['profile', 'email', 'openid'], + passReqToCallback: true, + }, + function (req, accessToken, refreshToken, profile, done) { + // asynchronous verification, for effect... + req.session.accessToken = accessToken; + process.nextTick(function () { + // To keep the example simple, the user's Linkedin profile is returned to + // represent the logged-in user. In a typical application, you would want + // to associate the Linkedin account with a user record in your database, + // and return that user instead. + return done(null, profile); + }); + } + ) +); var app = express(); @@ -69,12 +68,11 @@ app.use(passport.session()); app.use(app.router); app.use(express.static(__dirname + '/public')); - -app.get('/', function(req, res){ +app.get('/', function (req, res) { res.render('index', { user: req.user }); }); -app.get('/account', ensureAuthenticated, function(req, res){ +app.get('/account', ensureAuthenticated, function (req, res) { res.render('account', { user: req.user }); }); @@ -83,25 +81,29 @@ app.get('/account', ensureAuthenticated, function(req, res){ // request. The first step in Linkedin authentication will involve // redirecting the user to linkedin.com. After authorization, Linkedin // will redirect the user back to this application at /auth/linkedin/callback -app.get('/auth/linkedin', +app.get( + '/auth/linkedin', passport.authenticate('linkedin', { state: 'SOME STATE' }), - function(req, res){ + function (req, res) { // The request will be redirected to Linkedin for authentication, so this // function will not be called. - }); + } +); // GET /auth/linkedin/callback // Use passport.authenticate() as route middleware to authenticate the // request. If authentication fails, the user will be redirected back to the // login page. Otherwise, the primary route function function will be called, // which, in this example, will redirect the user to the home page. -app.get('/auth/linkedin/callback', +app.get( + '/auth/linkedin/callback', passport.authenticate('linkedin', { failureRedirect: '/login' }), - function(req, res) { + function (req, res) { res.redirect('/'); - }); + } +); -app.get('/logout', function(req, res){ +app.get('/logout', function (req, res) { req.logout(); res.redirect('/'); }); @@ -110,13 +112,14 @@ var http = require('http'); http.createServer(app).listen(3000); - // Simple route middleware to ensure user is authenticated. // Use this route middleware on any resource that needs to be protected. If // the request is authenticated (typically via a persistent login session), // the request will proceed. Otherwise, the user will be redirected to the // login page. function ensureAuthenticated(req, res, next) { - if (req.isAuthenticated()) { return next(); } + if (req.isAuthenticated()) { + return next(); + } res.redirect('/login'); } diff --git a/lib/oauth2.js b/lib/oauth2.js index 15e2ea9e..e34f1250 100644 --- a/lib/oauth2.js +++ b/lib/oauth2.js @@ -1,92 +1,60 @@ -var util = require('util') -var OAuth2Strategy = require('passport-oauth2') +var util = require('util'); +var OAuth2Strategy = require('passport-oauth2'); var InternalOAuthError = require('passport-oauth2').InternalOAuthError; -var liteProfileUrl = 'https://api.linkedin.com/v2/me?projection=(' + - 'id,' + - 'firstName,' + - 'lastName,' + - 'maidenName,' + - 'profilePicture(displayImage~:playableStreams)' + - ')'; - -// Most of these fields are only available for members of partner programs. -var basicProfileUrl = 'https://api.linkedin.com/v2/me?projection=(' + - 'id,' + - 'firstName,' + - 'lastName,' + - 'maidenName,' + - 'profilePicture(displayImage~:playableStreams),' + - 'phoneticFirstName,' + - 'phoneticLastName,' + - 'headline,' + - 'location,' + - 'industryId,' + - 'summary,' + - 'positions,' + - 'vanityName,' + - 'lastModified' + - ')'; +var profileUrl = 'https://api.linkedin.com/v2/userinfo'; function Strategy(options, verify) { options = options || {}; - options.authorizationURL = options.authorizationURL || 'https://www.linkedin.com/oauth/v2/authorization'; - options.tokenURL = options.tokenURL || 'https://www.linkedin.com/oauth/v2/accessToken'; - options.scope = options.scope || ['r_liteprofile']; + options.authorizationURL = + options.authorizationURL || + 'https://www.linkedin.com/oauth/v2/authorization'; + options.tokenURL = + options.tokenURL || 'https://www.linkedin.com/oauth/v2/accessToken'; + options.scope = options.scope || ['profile', 'email', 'openid']; //By default we want data in JSON - options.customHeaders = options.customHeaders || {"x-li-format":"json"}; + options.customHeaders = options.customHeaders || { 'x-li-format': 'json' }; OAuth2Strategy.call(this, options, verify); this.options = options; this.name = 'linkedin'; - this.profileUrl = options.scope.indexOf('r_basicprofile') !== -1 ? - basicProfileUrl : liteProfileUrl; - this.emailUrl = 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))'; + this.profileUrl = profileUrl; } util.inherits(Strategy, OAuth2Strategy); -Strategy.prototype.userProfile = function(accessToken, done) { +Strategy.prototype.userProfile = function (accessToken, done) { //LinkedIn uses a custom name for the access_token parameter - this._oauth2.setAccessTokenName("oauth2_access_token"); + this._oauth2.setAccessTokenName('oauth2_access_token'); - this._oauth2.get(this.profileUrl, accessToken, function (err, body, res) { - if (err) { - return done(new InternalOAuthError('failed to fetch user profile', err)); - } - - var profile; - - try { - profile = parseProfile(body); - } catch(e) { - return done(new InternalOAuthError('failed to parse profile response', e)); - } - - if (!this.options.scope.includes('r_emailaddress')) { - return done(null, profile); - } - - this._oauth2.get(this.emailUrl, accessToken, function (err, body, res) { + this._oauth2.get( + this.profileUrl, + accessToken, + function (err, body, _res) { if (err) { - return done(new InternalOAuthError('failed to fetch user email', err)); + return done( + new InternalOAuthError('failed to fetch user profile', err) + ); } + var profile; + try { - addEmails(profile, body); - } catch(e) { - return done(new InternalOAuthError('failed to parse email response', e)); + profile = parseProfile(body); + } catch (e) { + return done( + new InternalOAuthError('failed to parse profile response', e) + ); } - return done(null, profile); - }.bind(this)); + done(null, profile); + }.bind(this) + ); +}; - }.bind(this)); -} - -Strategy.prototype.authorizationParams = function(options) { +Strategy.prototype.authorizationParams = function (options) { var params = {}; // LinkedIn requires state parameter. It will return an error if not set. @@ -95,84 +63,22 @@ Strategy.prototype.authorizationParams = function(options) { } return params; -} - -function getName(nameObj) { - var locale = nameObj.preferredLocale.language + '_' + nameObj.preferredLocale.country; - return nameObj.localized[locale]; -} - -function getProfilePictures(profilePictureObj) { - // This is the format we used to return in the past. - var result = []; - - if(!profilePictureObj) { - // Picture is optional. - return result; - } - - try { - profilePictureObj['displayImage~'].elements.forEach(function(pic) { - // We keep only public profile pictures. - if(pic.authorizationMethod !== 'PUBLIC') { - return; - } - - // This should not happen, but... - if(pic.identifiers.length === 0) { - return; - } - - var url = pic.identifiers[0].identifier; - - result.push({ value: url }); - }); - } catch(e) { - // Profile picture object changed format? - return result; - } - - return result; -} +}; function parseProfile(body) { var json = JSON.parse(body); - var profile = { provider: 'linkedin' }; - - profile.id = json.id; - - profile.name = { - givenName: getName(json.firstName), - familyName: getName(json.lastName) + return { + provider: 'linkedin', + id: json.sub, + email: json.email, + givenName: json.given_name, + familyName: json.family_name, + displayName: `${json.given_name} ${json.family_name}`, + picture: json.picture, + _raw: body, + _json: json, }; - - profile.displayName = profile.name.givenName + ' ' + profile.name.familyName; - - profile.photos = getProfilePictures(json.profilePicture); - - profile._raw = body; - profile._json = json; - - return profile; -} - -function addEmails(profile, body) { - var json = JSON.parse(body); - - if(json.elements && json.elements.length > 0) { - profile.emails = json.elements.reduce(function (acc, el) { - if (el['handle~'] && el['handle~'].emailAddress) { - acc.push({ - value: el['handle~'].emailAddress - }); - } - return acc; - }, []); - } - - profile._emailRaw = body; - profile._emailJson = json; } module.exports = Strategy; diff --git a/test/basic-profile.json b/test/basic-profile.json deleted file mode 100644 index 616d1090..00000000 --- a/test/basic-profile.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "id":"REDACTED", - "firstName":{ - "localized":{ - "en_US":"Tina" - }, - "preferredLocale":{ - "country":"US", - "language":"en" - } - }, - "lastName":{ - "localized":{ - "en_US":"Belcher" - }, - "preferredLocale":{ - "country":"US", - "language":"en" - } - }, - "profilePicture":{ - "displayImage":"urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ", - "displayImage~":{ - "elements":[ - { - "artifact":"urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100)", - "authorizationMethod":"PUBLIC", - "data":{ - "com.linkedin.digitalmedia.mediaartifact.StillImage":{ - "storageSize":{ - "width":100, - "height":100 - }, - "storageAspectRatio":{ - "widthAspect":1, - "heightAspect":1, - "formatted":"1.00:1.00" - }, - "mediaType":"image/jpeg", - "rawCodecSpec":{ - "name":"jpeg", - "type":"image" - }, - "displaySize":{ - "uom":"PX", - "width":100, - "height":100 - }, - "displayAspectRatio":{ - "widthAspect":1, - "heightAspect":1, - "formatted":"1.00:1.00" - } - } - }, - "identifiers":[ - { - "identifier":"https://media.licdn.com/dms/image/C4D03AQGsitRwG8U8ZQ/profile-displayphoto-shrink_100_100/0?e=1526940000&v=alpha&t=12345", - "file":"urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100,0)", - "index":0, - "mediaType":"image/jpeg", - "identifierExpiresInSeconds":1526940000 - } - ] - } - ], - "paging":{ - "count":10, - "start":0, - "links":[ - - ] - } - } - } -} diff --git a/test/email-address.json b/test/email-address.json deleted file mode 100644 index 0f54666e..00000000 --- a/test/email-address.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "elements":[ - { - "handle":"urn:li:emailAddress:3775708763", - "handle~":{ - "emailAddress":"hsimpson@linkedin.com" - } - } - ] -} diff --git a/test/lite-profile.json b/test/lite-profile.json deleted file mode 100644 index 616d1090..00000000 --- a/test/lite-profile.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "id":"REDACTED", - "firstName":{ - "localized":{ - "en_US":"Tina" - }, - "preferredLocale":{ - "country":"US", - "language":"en" - } - }, - "lastName":{ - "localized":{ - "en_US":"Belcher" - }, - "preferredLocale":{ - "country":"US", - "language":"en" - } - }, - "profilePicture":{ - "displayImage":"urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ", - "displayImage~":{ - "elements":[ - { - "artifact":"urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100)", - "authorizationMethod":"PUBLIC", - "data":{ - "com.linkedin.digitalmedia.mediaartifact.StillImage":{ - "storageSize":{ - "width":100, - "height":100 - }, - "storageAspectRatio":{ - "widthAspect":1, - "heightAspect":1, - "formatted":"1.00:1.00" - }, - "mediaType":"image/jpeg", - "rawCodecSpec":{ - "name":"jpeg", - "type":"image" - }, - "displaySize":{ - "uom":"PX", - "width":100, - "height":100 - }, - "displayAspectRatio":{ - "widthAspect":1, - "heightAspect":1, - "formatted":"1.00:1.00" - } - } - }, - "identifiers":[ - { - "identifier":"https://media.licdn.com/dms/image/C4D03AQGsitRwG8U8ZQ/profile-displayphoto-shrink_100_100/0?e=1526940000&v=alpha&t=12345", - "file":"urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100,0)", - "index":0, - "mediaType":"image/jpeg", - "identifierExpiresInSeconds":1526940000 - } - ] - } - ], - "paging":{ - "count":10, - "start":0, - "links":[ - - ] - } - } - } -} diff --git a/test/profile.json b/test/profile.json new file mode 100644 index 00000000..4b66a69c --- /dev/null +++ b/test/profile.json @@ -0,0 +1,10 @@ +{ + "sub": "782bbtaQ", + "name": "John Doe", + "given_name": "John", + "family_name": "Doe", + "picture": "https://media.licdn-ei.com/dms/image/C5F03AQHqK8v7tB1HCQ/profile-displayphoto-shrink_100_100/0/", + "locale": "en-US", + "email": "doe@email.com", + "email_verified": true +} diff --git a/test/strategy.tests.js b/test/strategy.tests.js index e55e59b0..67494e9c 100644 --- a/test/strategy.tests.js +++ b/test/strategy.tests.js @@ -1,45 +1,38 @@ -const should = require("should"); +const should = require('should'); const nock = require('nock'); const Strategy = require('../lib/index').OAuth2Strategy; -const liteProfileExample = require('./lite-profile.json'); -const basicProfileExample = require('./basic-profile.json'); +const profileExample = require('./profile.json'); nock.disableNetConnect(); describe('LinkedIn Strategy', function () { const origin = 'https://api.linkedin.com'; - const liteProfilePath = '/v2/me?projection=(id%2CfirstName%2ClastName%2CmaidenName%2CprofilePicture(displayImage~%3AplayableStreams))&oauth2_access_token=whatever'; - - const emailPath = '/v2/emailAddress?q=members&projection=(elements*(handle~))&oauth2_access_token=whatever'; - - const basicProfilePath = '/v2/me?projection=(id%2CfirstName%2ClastName%2CmaidenName%2CprofilePicture(displayImage~%3AplayableStreams)%2CphoneticFirstName%2CphoneticLastName%2Cheadline%2Clocation%2CindustryId%2Csummary%2Cpositions%2CvanityName%2ClastModified)&oauth2_access_token=whatever'; + const profilePath = '/v2/userinfo?oauth2_access_token=whatever'; it('sanity check', function (done) { const options = { - clientID: "clientId", - clientSecret: "clientSecret" + clientID: 'clientId', + clientSecret: 'clientSecret', }; - const st = new Strategy(options, function () { }); + const st = new Strategy(options, function () {}); - st.name.should.eql("linkedin"); + st.name.should.eql('linkedin'); - const decodedProfilePath = decodeURIComponent(liteProfilePath).replace('&oauth2_access_token=whatever', ''); - const decodedEmailPath = decodeURIComponent(emailPath).replace('&oauth2_access_token=whatever', ''); + const decodedProfilePath = decodeURIComponent(profilePath).replace( + '?oauth2_access_token=whatever', + '' + ); st.profileUrl.should.eql(`${origin}${decodedProfilePath}`); - st.emailUrl.should.eql(`${origin}${decodedEmailPath}`); done(); }); describe('userProfile(accessToken, done)', function () { - - context('with r_liteprofile scope', function () { + context('with profile and email scope', function () { beforeEach(function () { - this.scope = nock(origin) - .get(liteProfilePath) - .reply(200, liteProfileExample); + this.scope = nock(origin).get(profilePath).reply(200, profileExample); }); afterEach(function () { @@ -48,122 +41,32 @@ describe('LinkedIn Strategy', function () { it('passes id, firstname, lastname and profile picture fields to callback', function (done) { const options = { - clientID: "clientId", - clientSecret: "clientSecret" - }; - - const st = new Strategy(options, function () { }); - - st.userProfile('whatever', function (err, profile) { - should.not.exist(err); - profile.id.should.eql('REDACTED'); - profile.name.givenName.should.eql('Tina'); - profile.name.familyName.should.eql('Belcher'); - profile.displayName.should.eql('Tina Belcher'); - profile.photos.should.eql([{ - value: 'https://media.licdn.com/dms/image/C4D03AQGsitRwG8U8ZQ/profile-displayphoto-shrink_100_100/0?e=1526940000&v=alpha&t=12345' - }]); - - done(); - }); - }); - }); - - context('with r_emailaddress scope', function () { - beforeEach(function () { - this.scope = nock(origin) - .get(liteProfilePath) - .reply(200, liteProfileExample) - .get(emailPath) - .reply(200, require('./email-address.json')); - }); - - afterEach(function() { - this.scope.done(); - }); - - it('passes also email field to callback', function (done) { - const options = { - clientID: "clientId", - clientSecret: "clientSecret", - scope: ['r_liteprofile', 'r_emailaddress'] - }; - - const st = new Strategy(options, function () { }); - - st.userProfile('whatever', function (err, profile) { - should.not.exist(err); - profile.id.should.eql('REDACTED'); - profile.emails.should.eql([ - { - value: 'hsimpson@linkedin.com' - } - ]); - done(); - }); - }); - }); - - context('with r_basicprofile scope', function () { - beforeEach(function () { - this.scope = nock(origin) - .get(basicProfilePath) - .reply(200, basicProfileExample); - }); - - afterEach(function () { - this.scope.done(); - }); - - it('passes all basic profile fields to callback', function (done) { - const options = { - clientID: "clientId", - clientSecret: "clientSecret", - scope: ['r_basicprofile'] + clientID: 'clientId', + clientSecret: 'clientSecret', + scope: ['profile', 'email', 'openid'], }; - const st = new Strategy(options, function () { }); + const st = new Strategy(options, function () {}); st.userProfile('whatever', function (err, profile) { should.not.exist(err); - profile.id.should.eql('REDACTED'); - profile.name.givenName.should.eql('Tina'); - profile.name.familyName.should.eql('Belcher'); - profile.displayName.should.eql('Tina Belcher'); - profile.photos.should.eql([{ - value: 'https://media.licdn.com/dms/image/C4D03AQGsitRwG8U8ZQ/profile-displayphoto-shrink_100_100/0?e=1526940000&v=alpha&t=12345' - }]); - - // TODO: add basic profile fields once we have a valid example. + profile.id.should.eql('782bbtaQ'); + profile.givenName.should.eql('John'); + profile.familyName.should.eql('Doe'); + profile.displayName.should.eql('John Doe'); + profile.picture.should.eql( + 'https://media.licdn-ei.com/dms/image/C5F03AQHqK8v7tB1HCQ/profile-displayphoto-shrink_100_100/0/' + ); + profile.email.should.eql('doe@email.com'); done(); }); }); - - it('should still request basic profile fields when used at the same time as r_liteprofile', function (done) { - const options = { - clientID: "clientId", - clientSecret: "clientSecret", - scope: ['r_liteprofile', 'r_basicprofile'] - }; - - const st = new Strategy(options, function () { }); - - st.userProfile('whatever', function (err, profile) { - - // TODO: check one basic profile field, other checks performed - // by nock in afterEach block. - - done(err); - }); - }); }); context('when error occurs', function () { beforeEach(function () { - this.scope = nock(origin) - .get(liteProfilePath) - .reply(500); + this.scope = nock(origin).get(profilePath).reply(500); }); afterEach(function () { @@ -172,12 +75,12 @@ describe('LinkedIn Strategy', function () { it('passes error to callback', function (done) { const options = { - clientID: "clientId", - clientSecret: "clientSecret", - scope: ['r_liteprofile', 'r_emailaddress'] + clientID: 'clientId', + clientSecret: 'clientSecret', + scope: ['profile', 'email', 'openid'], }; - const st = new Strategy(options, function () { }); + const st = new Strategy(options, function () {}); st.userProfile('whatever', function (err, profile) { should.exist(err); From 1952e376686ad935b5f63289d003b0a1f1066be4 Mon Sep 17 00:00:00 2001 From: Natalie Cluer Date: Wed, 20 Sep 2023 11:20:51 -0400 Subject: [PATCH 2/2] chore: bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bcd23c86..264a69c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passport-linkedin-oauth2", - "version": "2.0.0", + "version": "3.0.0", "description": "Passport for LinkedIn OAuth2 API v2", "main": "./lib", "repository": {