All files OAuth2Service.js

94.88% Statements 167/176
72.72% Branches 16/22
100% Functions 9/9
94.88% Lines 167/176

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 1761x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x         1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x
// DOC: https://developers.etsy.com/documentation/essentials/authentication
 
import fetch from "node-fetch";
import crypto from 'crypto';
import queryString from 'query-string';
 
const ETSY_OAUTH_CONNECT = "https://www.etsy.com/oauth/connect"
const ETSY_V3_API_OAUTH_TOKEN_URL = "https://api.etsy.com/v3/public/oauth/token"
const DEFAULT_SCOPES = ['shops_r','listings_r']
 
const OAUTH_DEBUG = process.env.OAUTH_DEBUG === "1" || false;
 
/**
 * ETSY OAUTH2 Workflow
 * ********************
 * based on example: https://developers.etsy.com/documentation/tutorials/quickstart
 *
 * 1) authenticate        : generate codeVerifier + state + connectUrl
 *                          set codeVerifier and state in session
 *                          redirect your user to connectUrl.
 *
 * User) grant permission and is redirected by etsy to redirect_uri
 *
 * 2) <incoming callback> : retrieve req.query.code && req.query.state
 *                          [query state and session state must match]
 *
 * 3) askForApiV3Token    : using code + session codeVerifier, fetch etsy token
 **/
class OAuth2Service {
 
    /**
     * 1) authenticate
     * example: https://developers.etsy.com/documentation/essentials/authentication/#step-1-request-an-authorization-code
     **/
    authenticate(client_id/*etsy api key*/, redirect_uri, scopes = DEFAULT_SCOPES) {
      const response_type = "code";
      const state = generateState();
      const codeVerifier = generateVerifier();
      const code_challenge_method = "S256";
      const code_challenge = generateS256Challenge(codeVerifier);
      const scope = scopes.join(' ');//space separated - https://developers.etsy.com/documentation/essentials/authentication/#scopes
      const queryStringObject = { response_type, redirect_uri, scope, client_id, state, code_challenge, code_challenge_method};
      OAUTH_DEBUG && console.log(`query: ${JSON.stringify(queryStringObject)}`);
 
      const connectUrl = ETSY_OAUTH_CONNECT + "?" + queryString.stringify(queryStringObject);
      // const connectUrl = `https://www.etsy.com/oauth/connect?response_type=code&redirect_uri=${callbackUrl}&scope=email_r&client_id=1aa2bb33c44d55eeeeee6fff&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
      OAUTH_DEBUG && console.log(`connectUrl: ${connectUrl}`)
 
      return { codeVerifier, state, connectUrl };
    }
 
    /* 2) server side - implement incoming callback - after 1) etsy will redirect user to redirect_uri with code and state query params
 
       example: https://www.exemple.com/api/v0/etsy/callback?code=OYPxG3eexxxwdBLS&state=ztxjkl
 
         router.get('/callback', async function(req, res, next) {
           const { code, state } = req.query;
           const { codeVerifier, codeState } = req.session.etsyContext;// you have to manage session etsy context data
           try {
             if (!codeVerifier || !codeState ) {
               throw "Missing connect session informations";
             } else if (codeState !== state) {
               throw "Wrong state";
             }
           // ... next step: cf 3)
 
       in order to develop and test oAuth2 locally, first push this temp redirect
 
          this way you could use https://www.exemple.com/api/v0/etsy/redirectDev0A2 as callback (dont forget to declare it on etsy)
 
          router.get('/redirectDev0A2', async function(req, res, next) {
            const originalParams = req.query;
            const endOfURl = originalParams ? '?'+queryString.stringify(originalParams) : '';
            res.redirect("http://localhost:3000/api/v0/etsy/callback" + endOfURl);
          });
 
     */
 
    /**
     * ask for an Etsy Open API V3 Token
     *
     * return tokenData {
     *  access_token  : Token to add as etsy api v3 header bearer value - s the OAuth grant token with a user id numeric prefix (12345678 in the example above), which is the internal user_id of the Etsy.com user who grants the application access. The V3 Open API requires the combined user id prefix and OAuth token as formatted in this parameter to authenticate requests.
     *  refresh_token : The Etsy Open API delivers a refresh token with the access token, which you can use to obtain a new access token through the refresh_token grant, and has a longer functional lifetime (90 days).
     *  token_type    : always Bearer which indicates that the OAuth token is a bearer token.
     *  expires_in    : is the valid duration of the OAuth token in seconds from the moment it is granted; 3600 seconds is 1 hour.
     *  expires_ts    : expire timestamp (second since 1970) * generated and added here under
     * }
     **/
    askForApiV3Token(client_id/*etsy api key*/, code, code_verifier, redirect_uri) {
 
      const grant_type = "authorization_code";
      const requestOptions = {
          method: 'POST',
          body: JSON.stringify({ grant_type, client_id, redirect_uri, code, code_verifier }),
          headers: {'Content-Type': 'application/json'}
      };
 
      return new Promise(function(resolve, reject) {
        const askTime = nowSec();
        fetch(ETSY_V3_API_OAUTH_TOKEN_URL, requestOptions)
        .then(async function(response) {
          if (response.ok) {
            var json = await response.json();
            var tokenData = Object.assign({}, json);
            // tokenData.expires_ts = askTime - 123;// DEV // simulate expired token
            tokenData.expires_ts = askTime + tokenData.expires_in;
            resolve(tokenData);
          } else {
            throw response;
          }
        })
        .catch(async function (response) {
           const status = response.status;
           const json = await response.json();
           OAUTH_DEBUG && console.log({status, json})
           reject(json);
        });
 
      });
 
    }
 
    // https://developers.etsy.com/documentation/essentials/authentication#requesting-a-refresh-oauth-token
    refreshApiV3Token(client_id/*etsy api key*/, refresh_token) {
      const grant_type = "refresh_token";
      const requestOptions = {
          method: 'POST',
          body: JSON.stringify({ grant_type, client_id, refresh_token }),
          headers: {'Content-Type': 'application/json'}
      };
 
      return new Promise(function(resolve, reject) {
 
        const askTime = nowSec();
        fetch(ETSY_V3_API_OAUTH_TOKEN_URL, requestOptions)
        .then(async function(response) {
          if (response.ok) {
            var json = await response.json();
            var tokenData = Object.assign({}, json);
            tokenData.expires_ts = askTime + tokenData.expires_in;
            resolve(tokenData);
          } else {
            throw response;
          }
        })
        .catch(async function (response) {
           const status = response.status;
           const json = await response.json();
           OAUTH_DEBUG && console.log({status, json})
           reject(json);
        });
 
      });
    }
 
}
export default OAuth2Service;
 
//~private
const base64URLEncode = (str) =>
  str
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
 
const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest();
 
const generateState = () => Math.random().toString(36).substring(7);
 
const generateVerifier = () => base64URLEncode(crypto.randomBytes(32));
 
const generateS256Challenge = (verifier) => base64URLEncode(sha256(verifier));
 
const nowSec = () => Math.floor(Date.now() / 1000); // second since 1 jan 70