From d389d4941780d8630bd0156941a4f1a9aae0bc80 Mon Sep 17 00:00:00 2001 From: Chase <44284917+TotallyNotChase@users.noreply.github.com> Date: Wed, 10 Jun 2020 13:08:06 +0530 Subject: [PATCH] Deploy full oauth functionality (#99) * Fix encodeURI params and some clean up (#98) (#4) * v2.7.7 * fix encodeURI issue * version bump Co-authored-by: Ajaykumar * Add OAUTH endpoint to constants * Add full oauth support * Add oauth endpoint to expected properties * Rename access_token -> appAccessToken * Remove oauth functions from utils * Add user token demo * Fix missing context var * Add missing function description * Add urldecode declaration to docstring * Remove trailling spaces * Migrate credential functions to credentials.js Co-authored-by: Ajaykumar --- demo/getUserToken.js | 32 ++++++++ src/buy-api.js | 20 ++--- src/common-utils/index.js | 22 +----- src/constants.js | 2 + src/credentials.js | 151 ++++++++++++++++++++++++++++++++++++++ src/index.js | 27 ++++++- src/taxonomy-api.js | 20 ++--- test/index.test.js | 1 + 8 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 demo/getUserToken.js create mode 100644 src/credentials.js diff --git a/demo/getUserToken.js b/demo/getUserToken.js new file mode 100644 index 0000000..d7f2de6 --- /dev/null +++ b/demo/getUserToken.js @@ -0,0 +1,32 @@ +const readline = require('readline'); +const Ebay = require('../src/index'); +const { clientId, clientSecret, redirectUri } = require('./credentials/index'); + +let ebay = new Ebay({ + clientID: clientId, + clientSecret: clientSecret, + redirectUri: redirectUri, + body: { + grant_type: 'authorization_code', + scope: 'https://api.ebay.com/oauth/api_scope' + } +}); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const authURL = ebay.getUserAuthorizationUrl(); +console.log(`Please go here for auth code: ${authURL}`); +rl.question("Enter the auth code recieved from the redirect url (should urldecode it first): ", code => { + rl.close(); + ebay.getUserTokenByCode(code).then(data => { + console.log('User token by code response:-'); + console.log(data); + ebay.getUserTokenByRefresh().then(data => { + console.log('User token by refresh token response:-'); + console.log(data); + }); + }) +}); diff --git a/src/buy-api.js b/src/buy-api.js index 9758c39..9b9c0a1 100644 --- a/src/buy-api.js +++ b/src/buy-api.js @@ -6,8 +6,8 @@ const { encodeURLQuery, base64Encode } = require('./common-utils'); const getItem = function (itemId) { if (!itemId) throw new Error('Item Id is required'); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; const id = encodeURIComponent(itemId); this.options.contentType = 'application/json'; return makeRequest(this.options, `/buy/browse/v1/item/${id}`, 'GET', auth).then((result) => { @@ -17,9 +17,9 @@ const getItem = function (itemId) { const getItemByLegacyId = function (legacyOptions) { if (!legacyOptions) throw new Error('Error Required input to get Items By LegacyID'); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); if (!legacyOptions.legacyItemId) throw new Error('Error Legacy Item Id is required'); - const auth = 'Bearer ' + this.options.access_token; + const auth = 'Bearer ' + this.options.appAccessToken; let param = 'legacy_item_id=' + legacyOptions.legacyItemId; param += legacyOptions.legacyVariationSku ? '&legacy_variation_sku=' + legacyOptions.legacyVariationSku : ''; this.options.contentType = 'application/json'; @@ -35,8 +35,8 @@ const getItemByLegacyId = function (legacyOptions) { const getItemByItemGroup = function (itemGroupId) { if (typeof itemGroupId === 'object') throw new Error('Expecting String or number (Item group id)'); if (!itemGroupId) throw new Error('Error Item Group ID is required'); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; this.options.contentType = 'application/json'; return new Promise((resolve, reject) => { makeRequest(this.options, `/buy/browse/v1/item/get_items_by_item_group?item_group_id=${itemGroupId}`, 'GET', auth).then((result) => { @@ -50,8 +50,8 @@ const getItemByItemGroup = function (itemGroupId) { const searchItems = function (searchConfig) { if (!searchConfig) throw new Error('Error --> Missing or invalid input parameter to search'); if (!searchConfig.keyword && !searchConfig.categoryId && !searchConfig.gtin) throw new Error('Error --> Keyword or category id is required in query param'); - if (!this.options.access_token) throw new Error('Error -->Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Error -->Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; let queryParam = searchConfig.keyword ? 'q=' + encodeURIComponent(searchConfig.keyword) : ''; queryParam = queryParam + (searchConfig.gtin ? '>in=' + searchConfig.gtin : ''); queryParam = queryParam + (searchConfig.categoryId ? '&category_ids=' + searchConfig.categoryId : ''); @@ -73,9 +73,9 @@ const searchItems = function (searchConfig) { const searchByImage = function (searchConfig) { if (!searchConfig) throw new Error('INVALID_REQUEST_PARMS --> Missing or invalid input parameter to search by image'); - if (!this.options.access_token) throw new Error('INVALID_AUTH_TOKEN --> Missing Access token, Generate access token'); + if (!this.options.appAccessToken) throw new Error('INVALID_AUTH_TOKEN --> Missing Access token, Generate access token'); if (!searchConfig.imgPath && !searchConfig.base64Image) throw new Error('REQUIRED_PARAMS --> imgPath or base64Image is required'); - const auth = 'Bearer ' + this.options.access_token; + const auth = 'Bearer ' + this.options.appAccessToken; const encodeImage = searchConfig.imgPath ? base64Encode(fs.readFileSync(searchConfig.imgPath)) : searchConfig.base64Image; this.options.data = JSON.stringify({ image: encodeImage }); this.options.contentType = 'application/json'; diff --git a/src/common-utils/index.js b/src/common-utils/index.js index d293867..e23c214 100644 --- a/src/common-utils/index.js +++ b/src/common-utils/index.js @@ -1,7 +1,7 @@ 'use strict'; const { makeRequest } = require('../request'); -const base64Encode = (encodeData) => { +const base64Encode = encodeData => { const buff = Buffer.from(encodeData);; return buff.toString('base64'); }; @@ -46,23 +46,6 @@ const constructAdditionalParams = (options) => { }; module.exports = { - setAccessToken: function (token) { - this.options.access_token = token; - }, - getAccessToken: function () { - if (!this.options.clientID) throw new Error('Missing Client ID'); - if (!this.options.clientSecret) throw new Error('Missing Client Secret or Cert Id'); - if (!this.options.body) throw new Error('Missing Body, required Grant type'); - const encodedStr = base64Encode(this.options.clientID + ':' + this.options.clientSecret); - const self = this; - const auth = 'Basic ' + encodedStr; - this.options.contentType = 'application/x-www-form-urlencoded'; - return makeRequest(this.options, '/identity/v1/oauth2/token', 'POST', auth).then((result) => { - const resultJSON = JSON.parse(result); - self.setAccessToken(resultJSON.access_token); - return resultJSON; - }); - }, setSiteId: function (siteId) { this.options.siteId = siteId; }, @@ -73,10 +56,7 @@ module.exports = { if (!isString(data)) data = data.toString(); return data.toUpperCase(); }, - - // Returns if a value is a string isString, - // Returns if object is empty or not isEmptyObj(obj) { for (let key in obj) { diff --git a/src/constants.js b/src/constants.js index 81ded07..50899aa 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,6 +1,8 @@ 'use strict'; module.exports = { + PROD_OAUTHENVIRONMENT_WEBENDPOINT: 'https://auth.ebay.com/oauth2/authorize', + SANDBOX_OAUTHENVIRONMENT_WEBENDPOINT: 'https://auth.sandbox.ebay.com/oauth2/authorize', PROD_BASE_URL: 'api.ebay.com', SANDBOX_BASE_URL: 'api.sandbox.ebay.com', BASE_SVC_URL: 'svcs.ebay.com', diff --git a/src/credentials.js b/src/credentials.js new file mode 100644 index 0000000..09f0f56 --- /dev/null +++ b/src/credentials.js @@ -0,0 +1,151 @@ +'use strict'; +const qs = require('querystring'); +const { base64Encode } = require('./common-utils'); +const { makeRequest } = require('./request'); +const DEFAULT_API_SCOPE = 'https://api.ebay.com/oauth/api_scope'; + +/** +* Generates an application access token for client credentials grant flow +* +* @return appAccessToken object +*/ +const getAccessToken = function () { + if (!this.options.clientID) throw new Error('Missing Client ID'); + if (!this.options.clientSecret) throw new Error('Missing Client Secret or Cert Id'); + if (!this.options.body) throw new Error('Missing Body, required Grant type'); + let scopesParam = this.options.body.scopes + ? Array.isArray(this.options.body.scopes) + ? this.options.body.scopes.join('%20') + : this.options.body.scopes + : DEFAULT_API_SCOPE; + this.options.data = qs.stringify({ + grant_type: 'client_credentials', + scope: scopesParam + }); + this.options.contentType = 'application/x-www-form-urlencoded'; + const self = this; + const encodedStr = base64Encode(this.options.clientID + ':' + this.options.clientSecret); + const auth = 'Basic ' + encodedStr; + return makeRequest(this.options, '/identity/v1/oauth2/token', 'POST', auth).then((result) => { + const resultJSON = JSON.parse(result); + if (!resultJSON.error) self.setAppAccessToken(resultJSON); + return resultJSON; + }); +}; + +/** + * Generates user consent authorization url + * + * @param state custom state value + * @return userConsentUrl +*/ +const getUserAuthorizationUrl = function (state = null) { + if (!this.options.clientID) throw new Error('Missing Client ID'); + if (!this.options.clientSecret) throw new Error('Missing Client Secret or Cert Id'); + if (!this.options.body) throw new Error('Missing Body, required Grant type'); + if (!this.options.redirectUri) throw new Error('redirect_uri is required for redirection after sign in\nkindly check here https://developer.ebay.com/api-docs/static/oauth-redirect-uri.html'); + let scopesParam = this.options.body.scopes + ? Array.isArray(this.options.body.scopes) + ? this.options.body.scopes.join('%20') + : this.options.body.scopes + : DEFAULT_API_SCOPE; + let queryParam = `client_id=${this.options.clientID}`; + queryParam += `&redirect_uri=${this.options.redirectUri}`; + queryParam += `&response_type=code`; + queryParam += `&scope=${scopesParam}`; + queryParam += state ? `&state=${state}` : ''; + return `${this.options.oauthEndpoint}?${queryParam}`; +}; + +/** + * Generates a User access token given auth code + * + * @param code code generated from browser using the method getUserAuthorizationUrl (should be urldecoded) + * @return userAccessToken object (with refresh_token) +*/ +const getUserTokenByCode = function (code) { + if (!code) throw new Error('Authorization code is required, to generate authorization code use getUserAuthorizationUrl method'); + if (!this.options.clientID) throw new Error('Missing Client ID'); + if (!this.options.clientSecret) throw new Error('Missing Client Secret or Cert Id'); + if (!this.options.redirectUri) throw new Error('redirect_uri is required for redirection after sign in\nkindly check here https://developer.ebay.com/api-docs/static/oauth-redirect-uri.html'); + this.options.data = qs.stringify({ + code: code, + grant_type: 'authorization_code', + redirect_uri: this.options.redirectUri + }); + this.options.contentType = 'application/x-www-form-urlencoded'; + const self = this; + const encodedStr = base64Encode(`${this.options.clientID}:${this.options.clientSecret}`); + const auth = `Basic ${encodedStr}`; + return makeRequest(this.options, '/identity/v1/oauth2/token', 'POST', auth).then(result => { + const resultJSON = JSON.parse(result); + if (!resultJSON.error) self.setUserAccessToken(resultJSON); + return resultJSON; + }); +}; + +/** + * Use a refresh token to update a User access token (Updating the expired access token) + * + * @param refreshToken refresh token, defaults to pre-assigned refresh token + * @param scopes array of scopes for the access token + * @return userAccessToken object (without refresh_token) +*/ +const getUserTokenByRefresh = function (refreshToken = null) { + if (!this.options.clientID) throw new Error('Missing Client ID'); + if (!this.options.clientSecret) throw new Error('Missing Client Secret or Cert Id'); + if (!this.options.body) throw new Error('Missing Body, required Grant type'); + if (!refreshToken && !this.options.refreshToken) { + throw new Error('Refresh token is required, to generate refresh token use getUserTokenByCode method'); // eslint-disable-line max-len + } + refreshToken = refreshToken ? refreshToken : this.options.refreshToken; + let scopesParam = this.options.body.scopes + ? Array.isArray(this.options.body.scopes) + ? this.options.body.scopes.join('%20') + : this.options.body.scopes + : DEFAULT_API_SCOPE; + this.options.data = qs.stringify({ + refresh_token: refreshToken, + grant_type: 'refresh_token', + scope: scopesParam + }); + this.options.contentType = 'application/x-www-form-urlencoded'; + const self = this; + const encodedStr = base64Encode(`${this.options.clientID}:${this.options.clientSecret}`); + const auth = `Basic ${encodedStr}`; + return makeRequest(this.options, '/identity/v1/oauth2/token', 'POST', auth).then(result => { + const resultJSON = JSON.parse(result); + if (!resultJSON.error) self.setUserAccessToken(resultJSON); + return resultJSON; + }); +}; + +/** + * Assign user access token and refresh token returned from authorization grant workflow (i.e getUserTokenByRefresh) + * + * @param userAccessToken userAccessToken obj returned from getUserTokenByCode or getAccessTokenByRefresh +*/ +const setUserAccessToken = function (userAccessToken) { + if (!userAccessToken.token_type === 'User Access Token') throw new Error('userAccessToken is either missing or invalid'); + if (userAccessToken.refresh_token) this.options.refreshToken = userAccessToken.refresh_token; + this.options.userAccessToken = userAccessToken.access_token; +}; + +/** + * Assign application access token returned from client credentials workflow (i.e getAccessToken) + * + * @param appAccessToken appAccessToken obj returned from getApplicationToken +*/ +const setAppAccessToken = function (appAccessToken) { + if (!appAccessToken.token_type === 'Application Access Token') throw new Error('appAccessToken is either missing or invalid'); + this.options.appAccessToken = appAccessToken.access_token; +}; + +module.exports = { + getAccessToken, + getUserAuthorizationUrl, + getUserTokenByCode, + getUserTokenByRefresh, + setUserAccessToken, + setAppAccessToken +}; diff --git a/src/index.js b/src/index.js index b01d660..1291564 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,22 @@ const taxonomyApi = require('./taxonomy-api'); const ebayFindingApi = require('./finding'); const commonUtils = require('./common-utils'); const { getSimilarItems, getMostWatchedItems } = require('./merchandising'); -const { PROD_BASE_URL, SANDBOX_BASE_URL, BASE_SANDBX_SVC_URL, BASE_SVC_URL } = require('./constants'); +const { + getAccessToken, + getUserAuthorizationUrl, + getUserTokenByCode, + getUserTokenByRefresh, + setAppAccessToken, + setUserAccessToken +} = require('./credentials'); +const { + PROD_OAUTHENVIRONMENT_WEBENDPOINT, + SANDBOX_OAUTHENVIRONMENT_WEBENDPOINT, + PROD_BASE_URL, + SANDBOX_BASE_URL, + BASE_SANDBX_SVC_URL, + BASE_SVC_URL +} = require('./constants'); const PROD_ENV = 'PROD'; const SANDBOX_ENV = 'SANDBOX'; @@ -15,12 +30,12 @@ const SANDBOX_ENV = 'SANDBOX'; * * @param {Object} options configuration options * @param {String} options.clientID Client Id/App id + * @param {String} options.clientSecret eBay Secret/Cert ID - required for user access tokens * @param {String} options.env Environment, defaults to PROD * @param {String} options.headers HTTP request headers * @constructor * @public */ - function Ebay(options) { if (!options) throw new Error('Options is missing, please provide the input'); if (!options.clientID) throw Error('Client ID is Missing\ncheck documentation to get Client ID http://developer.ebay.com/DevZone/account/'); @@ -28,10 +43,12 @@ function Ebay(options) { if (!options.env) options.env = PROD_ENV; options.baseUrl = PROD_BASE_URL; options.baseSvcUrl = BASE_SVC_URL; + options.oauthEndpoint = PROD_OAUTHENVIRONMENT_WEBENDPOINT; // handle sandbox env. if (options.env === SANDBOX_ENV) { options.baseUrl = SANDBOX_BASE_URL; options.baseSvcUrl = BASE_SANDBX_SVC_URL; + options.oauthEndpoint = SANDBOX_OAUTHENVIRONMENT_WEBENDPOINT; } this.options = options; commonUtils.setHeaders(this, options.headers); @@ -40,6 +57,12 @@ function Ebay(options) { } Ebay.prototype = { + getAccessToken, + getUserAuthorizationUrl, + getUserTokenByCode, + getUserTokenByRefresh, + setUserAccessToken, + setAppAccessToken, getMostWatchedItems, getSimilarItems, ...commonUtils, diff --git a/src/taxonomy-api.js b/src/taxonomy-api.js index b36857f..a1fba80 100644 --- a/src/taxonomy-api.js +++ b/src/taxonomy-api.js @@ -11,8 +11,8 @@ const DEFAULT_CATEGORY_TREE = 'EBAY_US'; const getDefaultCategoryTreeId = function (marketPlaceId) { if (!marketPlaceId) marketPlaceId = DEFAULT_CATEGORY_TREE; marketPlaceId = upperCase(marketPlaceId); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; return makeRequest(this.options, `/commerce/taxonomy/v1_beta/get_default_category_tree_id?marketplace_id=${marketPlaceId}`, 'GET', auth).then((result) => { return JSON.parse(result); }); @@ -25,8 +25,8 @@ const getDefaultCategoryTreeId = function (marketPlaceId) { const getCategoryTree = function (categoryTreeId) { if (!categoryTreeId) categoryTreeId = 0; - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; return makeRequest(this.options, `/commerce/taxonomy/v1_beta/category_tree/${categoryTreeId}`, 'GET', auth).then((result) => { return JSON.parse(result); }); @@ -41,8 +41,8 @@ const getCategoryTree = function (categoryTreeId) { const getCategorySubtree = function (categoryTreeId, categoryId) { if (!categoryTreeId) categoryTreeId = 0; if (!categoryId) throw new Error('Missing Categor id \n Refer documentation here https://developer.ebay.com/api-docs/commerce/taxonomy/resources/category_tree/methods/getCategorySubtree#h2-samples'); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; return makeRequest(this.options, `/commerce/taxonomy/v1_beta/category_tree/${categoryTreeId}/get_category_subtree?category_id=${categoryId}`, 'GET', auth).then((result) => { return JSON.parse(result); }); @@ -57,8 +57,8 @@ const getCategorySubtree = function (categoryTreeId, categoryId) { const getCategorySuggestions = function (categoryTreeId, keyword) { if (!categoryTreeId) categoryTreeId = 0; if (!keyword) throw new Error('Missing keyword \n Refer documentation here https://developer.ebay.com/api-docs/commerce/taxonomy/resources/category_tree/methods/getCategorySuggestions'); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; return makeRequest(this.options, `/commerce/taxonomy/v1_beta/category_tree/${categoryTreeId}/get_category_suggestions?q=${keyword}`, 'GET', auth).then((result) => { return JSON.parse(result); }); @@ -72,8 +72,8 @@ const getCategorySuggestions = function (categoryTreeId, keyword) { const getItemAspectsForCategory = function (categoryTreeId, categoryId) { if (!categoryTreeId) categoryTreeId = 0; if (!categoryId) throw new Error('Missing Category id \n Refer documentation here https://developer.ebay.com/api-docs/commerce/taxonomy/resources/category_tree/methods/getItemAspectsForCategory#h2-samples'); - if (!this.options.access_token) throw new Error('Missing Access token, Generate access token'); - const auth = 'Bearer ' + this.options.access_token; + if (!this.options.appAccessToken) throw new Error('Missing Access token, Generate access token'); + const auth = 'Bearer ' + this.options.appAccessToken; return makeRequest(this.options, `/commerce/taxonomy/v1_beta/category_tree/${categoryTreeId}/get_item_aspects_for_category?category_id=${categoryId}`, 'GET', auth).then((result) => { return JSON.parse(result); }); diff --git a/test/index.test.js b/test/index.test.js index bffd415..69bfaab 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -34,6 +34,7 @@ describe('check all the options provided is valid or not - Ebay Constructor ', ( env: 'PROD', baseUrl: 'api.ebay.com', baseSvcUrl: 'svcs.ebay.com', + oauthEndpoint: 'https://auth.ebay.com/oauth2/authorize', globalID: 'EBAY-US', siteId: '0' };