Repository: lpinca/shopify-token Branch: master Commit: 23b559e5ce42 Files: 13 Total size: 32.9 KB Directory structure: gitextract_fvi5qwv6/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── example/ │ ├── README.md │ ├── config.json │ ├── index.js │ └── package.json ├── index.js ├── package.json ├── test.js └── types/ └── index.d.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: - push - pull_request jobs: test: runs-on: ubuntu-latest strategy: matrix: node: - 14 - 16 - 18 - 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm install - run: npm test - uses: coverallsapp/github-action@v2 if: matrix.node == 20 with: github-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ node_modules/ coverage/ ================================================ FILE: .npmrc ================================================ package-lock=false ================================================ FILE: LICENSE ================================================ Copyright (c) 2016 Luigi Pinca Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # shopify-token [![Version npm][npm-shopify-token-badge]][npm-shopify-token] [![Build Status][ci-shopify-token-badge]][ci-shopify-token] [![Coverage Status][coverage-shopify-token-badge]][coverage-shopify-token] This module helps you retrieve an access token for the Shopify REST API. It provides some convenience methods that can be used when implementing the [OAuth 2.0 flow][shopify-oauth-doc]. No assumptions are made about your server-side architecture, allowing the module to easily adapt to any setup. ## Install ``` npm install --save shopify-token ``` ## API The module exports a class whose constructor takes an options object. ### `new ShopifyToken(options)` Creates a new `ShopifyToken` instance. #### Arguments - `options` - A plain JavaScript object, e.g. `{ apiKey: 'YOUR_API_KEY' }`. #### Options - `apiKey` - Required - A string that specifies the API key of your app. - `sharedSecret` - Required - A string that specifies the shared secret of your app. - `redirectUri` - Required - A string that specifies the URL where you want to redirect the users after they authorize the app. - `scopes` - Optional - An array of strings or a comma-separated string that specifies the list of scopes e.g. `'read_content,read_themes'`. Defaults to `'read_content'`. - `timeout` - Optional - A number that specifies the milliseconds to wait for the server to send a response to the HTTPS request initiated by the `getAccessToken` method before aborting it. Defaults to 60000, or 1 minute. - `accessMode` - Optional - A string representing the [API access modes][api-access-mode]. Set this option to `'per-user'` to receive an access token that respects the user's permission level when making API requests (called online access). This is strongly recommended for embedded apps. Defaults to offline access mode. - `agent` - Optional - An HTTPS agent which will be passed to the HTTPS request made for obtaining the auth token. This is useful when trying to obtain a token from a server that has restrictions on internet access. #### Return value A `ShopifyToken` instance. #### Exceptions Throws a `Error` exception if the required options are missing. #### Example ```js const ShopifyToken = require('shopify-token'); const shopifyToken = new ShopifyToken({ sharedSecret: '8ceb18e8ca581aee7cad1ddd3991610b', redirectUri: 'http://localhost:8080/callback', apiKey: 'e74d25b9a6f2b15f2836c954ea8c1711' }); ``` ### `shopifyToken.generateNonce()` Generates a random nonce. #### Return value A string representing the nonce. #### Example ```js const nonce = shopifyToken.generateNonce(); console.log(nonce); // => 212a8b839860d1aefb258aaffcdbd63f ``` ### `shopifyToken.generateAuthUrl(shop[, scopes[, nonce[, accessMode]]])` Builds and returns the authorization URL where you should redirect the user. #### Arguments - `shop` - A string that specifies the name of the user's shop. - `scopes` - An optional array of strings or comma-separated string to specify the list of scopes. This allows you to override the default scopes. - `nonce` - An optional string representing the nonce. If not provided it will be generated automatically. - `accessMode` - An optional string dictating the API access mode. If not provided the access mode defined by the `accessMode` constructor option will be used. #### Return value A string representing the URL where the user should be redirected. #### Example ```js const url = shopifyToken.generateAuthUrl('dolciumi'); console.log(url); // => https://dolciumi.myshopify.com/admin/oauth/authorize?scope=read_content&state=7194ee27dd47ac9efb0ad04e93750e64&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&client_id=e74d25b9a6f2b15f2836c954ea8c1711 ``` ### `shopifyToken.verifyHmac(query)` Every request or redirect from Shopify to the client server includes a hmac parameter that can be used to ensure that it came from Shopify. This method validates the hmac parameter. #### Arguments - `query` - The parsed query string object. #### Return value `true` if the hmac is valid, else `false`. #### Example ```js const ok = shopifyToken.verifyHmac({ hmac: 'd1c59b480761bdabf7ee7eb2c09a3d84e71b1d37991bc2872bea8a4c43f8b2b3', signature: '184559898f5bbd1301606e7919c6e67f', state: 'b77827e928ee8eee614b5808d3276c8a', code: '4d732838ad8c22cd1d2dd96f8a403fb7', shop: 'dolciumi.myshopify.com', timestamp: '1452342558' }); console.log(ok); // => true ``` ### `shopifyToken.getAccessToken(hostname, code)` Exchanges the authorization code for a permanent access token. #### Arguments - `hostname` - A string that specifies the hostname of the user's shop. e.g. `foo.myshopify.com`. You can get this from the `shop` parameter passed by Shopify in the confirmation redirect. - `code` - The authorization Code. You can get this from the `code` parameter passed by Shopify in the confirmation redirect. #### Return value A `Promise` which gets resolved with an access token and additional data. When the exchange fails, you can read the HTTPS response status code and body from the `statusCode` and `responseBody` properties which are added to the error object. #### Example ```js const code = '4d732838ad8c22cd1d2dd96f8a403fb7'; const hostname = 'dolciumi.myshopify.com'; shopifyToken .getAccessToken(hostname, code) .then((data) => { console.log(data); // => { access_token: 'f85632530bf277ec9ac6f649fc327f17', scope: 'read_content' } }) .catch((err) => console.err(err)); ``` ## License [MIT](LICENSE) [api-access-mode]: https://shopify.dev/apps/auth/access-modes [npm-shopify-token-badge]: https://img.shields.io/npm/v/shopify-token.svg [npm-shopify-token]: https://www.npmjs.com/package/shopify-token [ci-shopify-token-badge]: https://img.shields.io/github/actions/workflow/status/lpinca/shopify-token/ci.yml?branch=master&label=CI [ci-shopify-token]: https://github.com/lpinca/shopify-token/actions?query=workflow%3ACI+branch%3Amaster [coverage-shopify-token-badge]: https://img.shields.io/coveralls/lpinca/shopify-token/master.svg [coverage-shopify-token]: https://coveralls.io/r/lpinca/shopify-token?branch=master [shopify-oauth-doc]: https://shopify.dev/apps/auth/oauth ================================================ FILE: example/README.md ================================================ # Example This example shows you how you can use `shopify-token` with `express`. To run it, edit the `config.json` file and add the client credentials, then install the dependencies: ``` npm install ``` After this you can start the server using `node index.js`. When the server is running, point your browser to [http://localhost:8080](http://localhost:8080). ================================================ FILE: example/config.json ================================================ { "redirectUri": "http://localhost:8080/callback", "sharedSecret": "APP_SECRET", "apiKey": "APP_KEY", "shop": "SHOP_NAME" } ================================================ FILE: example/index.js ================================================ 'use strict'; const session = require('express-session'); const express = require('express'); const ShopifyToken = require('..'); const config = require('./config'); const shopifyToken = new ShopifyToken(config); const app = express(); app.use(session({ secret: 'eo3Athuo4Ang5gai', saveUninitialized: false, resave: false })); app.get('/', (req, res) => { if (req.session.token) return res.send('Token ready to be used'); // // Generate a random nonce. // const nonce = shopifyToken.generateNonce(); // // Generate the authorization URL. For the sake of simplicity the shop name // is fixed here but it can, of course, be passed along with the request and // be different for each request. // const uri = shopifyToken.generateAuthUrl(config.shop, undefined, nonce); // // Save the nonce in the session to verify it later. // req.session.state = nonce; res.redirect(uri); }); app.get('/callback', (req, res) => { const state = req.query.state; if ( typeof state !== 'string' || state !== req.session.state // Validate the state. || !shopifyToken.verifyHmac(req.query) // Validate the hmac. ) { return res.status(400).send('Security checks failed'); } // // Exchange the authorization code for a permanent access token. // shopifyToken.getAccessToken(req.query.shop, req.query.code) .then((data) => { const token = data.access_token; console.log(token); req.session.token = token; req.session.state = undefined; res.redirect('/'); }) .catch((err) => { console.error(err.stack); res.status(500).send('Oops, something went wrong'); }); }); app.listen(8080, () => console.log('Open http://localhost:8080 in your browser')); ================================================ FILE: example/package.json ================================================ { "name": "shopify-token-example", "version": "1.0.0", "description": "", "main": "index.js", "keywords": [ "shopify-token", "example" ], "author": "Luigi Pinca", "license": "MIT", "dependencies": { "express": "^4.13.3", "express-session": "^1.12.1" } } ================================================ FILE: index.js ================================================ 'use strict'; const crypto = require('crypto'); const https = require('https'); const url = require('url'); /** * Encode a string by replacing each instance of the `&` and `%` characters * with `%26` and `%25` respectively. * * @param {String} input The input string * @return {String} The encoded string * @private */ const encodeValue = (input) => input.replace(/[%&]/g, encodeURIComponent); /** * Encode a string by replacing each instance of the `&`, `%` and `=` characters * with `%26`, `%25` and `%3D` respectively. * * @param {String} input The input string * @return {String} The encoded string * @private */ const encodeKey = (input) => input.replace(/[%&=]/g, encodeURIComponent); /** * Check whether two buffers have exactly the same bytes without leaking timing * information. * * @param {Buffer} a One buffer to be tested for equality * @param {Buffer} b The other buffer to be tested for equality * @return {Boolean} `true` if `a` and `b` have exactly the same bytes, else * `false` * @private */ function timingSafeEqual(a, b) { let result = 0; for (let i = 0; i < a.length; i++) { result |= a[i] ^ b[i]; } return result === 0; } /** * ShopifyToken class. */ class ShopifyToken { /** * Create a ShopifyToken instance. * * @param {Object} options Configuration options * @param {String} options.redirectUri The redirect URL for the Oauth2 flow * @param {String} options.sharedSecret The Shared Secret for the app * @param {Array|String} [options.scopes] The list of scopes * @param {String} options.apiKey The API Key for the app * @param {String} [options.accessMode] The API access mode * @param {Number} [options.timeout] The request timeout * @param {https.Agent} [options.agent] The agent used for all HTTP requests */ constructor(options) { if ( !options || !options.sharedSecret || !options.redirectUri || !options.apiKey ) { throw new Error('Missing or invalid options'); } this.accessMode = 'accessMode' in options ? options.accessMode : ''; this.scopes = 'scopes' in options ? options.scopes : 'read_content'; this.timeout = 'timeout' in options ? options.timeout : 60000; this.sharedSecret = options.sharedSecret; this.redirectUri = options.redirectUri; this.apiKey = options.apiKey; this.agent = options.agent; } /** * Generate a random nonce. * * @return {String} The random nonce * @public */ generateNonce() { return crypto.randomBytes(16).toString('hex'); } /** * Build the authorization URL. * * @param {String} shop The shop name * @param {Array|String} [scopes] The list of scopes * @param {String} [nonce] The nonce * @param {String} [accessMode] The API access mode * @return {String} The authorization URL * @public */ generateAuthUrl(shop, scopes, nonce, accessMode) { scopes || (scopes = this.scopes); accessMode || (accessMode = this.accessMode); const query = { scope: Array.isArray(scopes) ? scopes.join(',') : scopes, state: nonce || this.generateNonce(), redirect_uri: this.redirectUri, client_id: this.apiKey }; if (accessMode) { query['grant_options[]'] = accessMode; } return url.format({ pathname: '/admin/oauth/authorize', hostname: shop.endsWith('.myshopify.com') ? shop : `${shop}.myshopify.com`, protocol: 'https:', query }); } /** * Verify the hmac returned by Shopify. * * @param {Object} query The parsed query string * @return {Boolean} `true` if the hmac is valid, else `false` * @public */ verifyHmac(query) { const pairs = Object.keys(query) .filter((key) => key !== 'signature' && key !== 'hmac') .map((key) => { const value = Array.isArray(query[key]) ? `["${query[key].join('", "')}"]` : String(query[key]); return `${encodeKey(key)}=${encodeValue(value)}`; }) .sort(); if ( typeof query.hmac !== 'string' || Buffer.byteLength(query.hmac) !== 64 ) { return false; } const digest = crypto.createHmac('sha256', this.sharedSecret) .update(pairs.join('&')) .digest(); return timingSafeEqual(digest, Buffer.from(query.hmac, 'hex')); } /** * Request an access token. * * @param {String} shop The hostname of the shop, e.g. foo.myshopify.com * @param {String} code The authorization code * @return {Promise} Promise which is fulfilled with an access token and * additional data * @public */ getAccessToken(shop, code) { return new Promise((resolve, reject) => { const data = JSON.stringify({ client_secret: this.sharedSecret, client_id: this.apiKey, code }); const request = https.request({ headers: { 'Content-Length': Buffer.byteLength(data), 'Content-Type': 'application/json', 'Accept': 'application/json' }, path: '/admin/oauth/access_token', hostname: shop, method: 'POST', agent: this.agent }); let timer = setTimeout(() => { request.abort(); timer = null; reject(new Error('Request timed out')); }, this.timeout); request.on('response', (response) => { const status = response.statusCode; let body = ''; response.setEncoding('utf8'); response.on('data', (chunk) => body += chunk); response.on('end', () => { let error; if (!timer) return; clearTimeout(timer); if (status !== 200) { error = new Error('Failed to get Shopify access token'); error.responseBody = body; error.statusCode = status; return reject(error); } try { body = JSON.parse(body); } catch (e) { error = new Error('Failed to parse the response body'); error.responseBody = body; error.statusCode = status; return reject(error); } resolve(body); }); }); request.on('error', (err) => { if (!timer) return; clearTimeout(timer); reject(err); }); request.end(data); }); } } module.exports = ShopifyToken; ================================================ FILE: package.json ================================================ { "name": "shopify-token", "version": "4.1.0", "description": "Get an OAuth 2.0 access token for the Shopify API with ease", "homepage": "https://github.com/lpinca/shopify-token", "bugs": "https://github.com/lpinca/shopify-token/issues", "repository": "lpinca/shopify-token", "author": "Luigi Pinca", "license": "MIT", "main": "index.js", "engines": { "node": ">=6.6.0" }, "scripts": { "test": "c8 --reporter=lcov --reporter=text mocha" }, "files": [ "index.js", "types/index.d.ts" ], "types": "types", "keywords": [ "shopify", "token", "api", "oauth" ], "devDependencies": { "c8": "^7.3.0", "chai": "^4.2.0", "mocha": "^10.0.0", "nock": "^13.0.4", "pre-commit": "^1.2.2" } } ================================================ FILE: test.js ================================================ describe('shopify-token', function () { 'use strict'; const expect = require('chai').expect; const stream = require('stream'); const https = require('https'); const nock = require('nock'); const url = require('url'); const ShopifyToken = require('.'); const shopifyToken = new ShopifyToken({ sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz' }); it('exports the class', function () { expect(ShopifyToken).to.be.a('function'); }); it('throws an error when the required options are missing', function () { expect(() => { new ShopifyToken(); }).to.throw(Error, /Missing or invalid options/); expect(() => { new ShopifyToken({ scopes: 'write_content' }); }).to.throw(Error, /Missing or invalid options/); }); it('uses a default scope', function () { expect(shopifyToken.scopes).to.equal('read_content'); }); it('defaults to offline access mode', function () { expect(shopifyToken.accessMode).to.equal(''); }); it('allows to customize the default scopes', function () { const shopifyToken = new ShopifyToken({ scopes: 'read_content,write_content', sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz' }); expect(shopifyToken.scopes).to.equal('read_content,write_content'); }); it('allows to customize the default access mode', function () { const shopifyToken = new ShopifyToken({ accessMode: 'per-user', sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz' }); expect(shopifyToken.accessMode).to.equal('per-user'); }); it('allows to customize the request timeout', function () { const shopifyToken = new ShopifyToken({ sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz', timeout: 300 }); expect(shopifyToken.timeout).to.equal(300); }); describe('#generateNonce', function () { it('generates a random nonce', function () { const nonce = shopifyToken.generateNonce(); expect(nonce).to.be.a('string').and.have.length(32); }); }); describe('#generateAuthUrl', function () { it('builds the authorization URL', function () { const uri = shopifyToken.generateAuthUrl('qux'); const nonce = url.parse(uri, true).query.state; expect(nonce).to.be.a('string').and.have.length(32); expect(uri).to.equal(url.format({ pathname: '/admin/oauth/authorize', hostname: 'qux.myshopify.com', protocol: 'https:', query: { scope: 'read_content', state: nonce, redirect_uri: 'bar', client_id: 'baz' } })); }); it("allows to use the shop's myshopify.com domain as shop name", function () { const uri = shopifyToken.generateAuthUrl('qux.myshopify.com'); const nonce = url.parse(uri, true).query.state; expect(nonce).to.be.a('string').and.have.length(32); expect(uri).to.equal(url.format({ pathname: '/admin/oauth/authorize', hostname: 'qux.myshopify.com', protocol: 'https:', query: { scope: 'read_content', state: nonce, redirect_uri: 'bar', client_id: 'baz' } })); }); it('allows to override the default scopes', function () { const uri = shopifyToken.generateAuthUrl('qux', 'read_themes,read_products'); const nonce = url.parse(uri, true).query.state; expect(nonce).to.be.a('string').and.have.length(32); expect(uri).to.equal(url.format({ pathname: '/admin/oauth/authorize', hostname: 'qux.myshopify.com', protocol: 'https:', query: { scope: 'read_themes,read_products', state: nonce, redirect_uri: 'bar', client_id: 'baz' } })); }); it('allows to use an array to override the scopes', function () { const uri = shopifyToken.generateAuthUrl('qux', [ 'read_products', 'read_themes' ]); const nonce = url.parse(uri, true).query.state; expect(nonce).to.be.a('string').and.have.length(32); expect(uri).to.equal(url.format({ pathname: '/admin/oauth/authorize', hostname: 'qux.myshopify.com', protocol: 'https:', query: { scope: 'read_products,read_themes', state: nonce, redirect_uri: 'bar', client_id: 'baz' } })); }); it('allows to use a custom nonce', function () { const uri = shopifyToken.generateAuthUrl('qux', undefined, 'corge'); expect(uri).to.equal(url.format({ pathname: '/admin/oauth/authorize', hostname: 'qux.myshopify.com', protocol: 'https:', query: { scope: 'read_content', state: 'corge', redirect_uri: 'bar', client_id: 'baz' } })); }); it('allows to override the default access mode', function () { const uri = shopifyToken.generateAuthUrl( 'qux', undefined, undefined, 'per-user' ); const nonce = url.parse(uri, true).query.state; expect(nonce).to.be.a('string').and.have.length(32); expect(uri).to.equal(url.format({ pathname: '/admin/oauth/authorize', hostname: 'qux.myshopify.com', protocol: 'https:', query: { scope: 'read_content', state: nonce, redirect_uri: 'bar', client_id: 'baz', 'grant_options[]': 'per-user' } })); }); }); describe('#verifyHmac', function () { it('returns true if the message is authentic', function () { expect(shopifyToken.verifyHmac({ hmac: '3d9b9a7918ac20dfd03b6a0af54a58f0a47980145ae81a37f41597a1e34b528d', state: 'b77827e928ee8eee614b5808d3276c8a', code: '4d732838ad8c22cd1d2dd96f8a403fb7', shop: 'qux.myshopify.com', timestamp: '1451929074' })).to.equal(true); expect(shopifyToken.verifyHmac({ hmac: 'ffe89c5d47dd26297d47b68e6ad14cf4ee6f11a72b3da7c7a0974d0c3959579a', shop: 'qux.myshopify.com', timestamp: '1492784493', quuz: [1, 2], corge: 'grault' })).to.equal(true); }); it('returns false if the message is not authentic', function () { expect(shopifyToken.verifyHmac({ hmac: '3d9b9a7918ac20dfd03b6a0af54a58f0a47980145ae81a37f41597a1e34b528d', state: 'b77827e928ee8eee614b5808d3276c8a', code: '4d732838ad8c22cd1d2dd96f8a403fb7', shop: 'qux.myshopify.com', timestamp: '1451933938' })).to.equal(false); }); it('returns false if the query object is empty', function () { expect(shopifyToken.verifyHmac({})).to.equal(false); }); }); describe('#getAccessToken', function () { const pathname = '/admin/oauth/access_token'; const hostname = 'qux.myshopify.com'; const scope = nock(`https://${hostname}`, { allowUnmocked: true }); afterEach(function () { expect(scope.isDone()).to.be.true; }); it('exchanges the auth code for the access token', function () { const code = '4d732838ad8c22cd1d2dd96f8a403fb7'; const reply = { access_token: 'f85632530bf277ec9ac6f649fc327f17', scope: 'read_content' }; scope .post(pathname, { client_secret: 'foo', client_id: 'baz', code }) .reply(200, reply); return shopifyToken.getAccessToken(hostname, code) .then((data) => expect(data).to.deep.equal(reply)); }); it('honors the `agent` option', function () { const code = '4d732838ad8c22cd1d2dd96f8a403fb7'; const requestBody = { client_secret: 'foo', client_id: 'baz', code }; const stringifiedRequestBody = JSON.stringify(requestBody); const responseBody = { access_token: 'f85632530bf277ec9ac6f649fc327f17', scope: 'read_content' }; const stringifiedResponseBody = JSON.stringify(responseBody); const agent = new https.Agent(); agent.createConnection = function () { const duplex = new stream.Duplex({ read() {}, write(chunk, encoding, callback) { if (chunk.length === 0) { callback(); return; } expect(chunk.toString()).to.equal([ `POST ${pathname} HTTP/1.1`, `Content-Length: ${Buffer.byteLength(stringifiedRequestBody)}`, 'Content-Type: application/json', 'Accept: application/json', `Host: ${hostname}`, 'Connection: close', '', stringifiedRequestBody ].join('\r\n')); duplex.push([ 'HTTP/1.1 200 OK', 'Content-Type: application/json', `Content-Length: ${Buffer.byteLength(stringifiedResponseBody)}`, 'Connection: close', `Date: ${new Date().toUTCString()}`, '', stringifiedResponseBody ].join('\r\n')); callback(); } }); return duplex; } const shopifyToken = new ShopifyToken({ sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz', agent }); return shopifyToken.getAccessToken(hostname, code) .then((data) => expect(data).to.deep.equal(responseBody)); }); it('returns an error if the request fails', function () { const message = 'Something wrong happened'; scope .post(pathname) .replyWithError(message); return shopifyToken.getAccessToken(hostname, '123456').then(() => { throw new Error('Test invalidation'); }, (err) => { expect(err).to.be.an.instanceof(Error); expect(err.message).to.equal(message); }); }); it('returns an error when timeout expires (headers)', function () { const shopifyToken = new ShopifyToken({ sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz', timeout: 100 }); scope .post(pathname) .delay({ head: 200 }) .reply(200, {}); return shopifyToken.getAccessToken(hostname, '123456').then(() => { throw new Error('Test invalidation'); }, (err) => { expect(err).to.be.an.instanceof(Error); expect(err.message).to.equal('Request timed out'); }); }); it('returns an error when timeout expires (body)', function () { const shopifyToken = new ShopifyToken({ sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz', timeout: 100 }); scope .post(pathname) .delay({ body: 200 }) .reply(200, {}); return shopifyToken.getAccessToken(hostname, '123456').then(() => { throw new Error('Test invalidation'); }, (err) => { expect(err).to.be.an.instanceof(Error); expect(err.message).to.equal('Request timed out'); }); }); it('returns an error when timeout expires (connection)', function () { const shopifyToken = new ShopifyToken({ sharedSecret: 'foo', redirectUri: 'bar', apiKey: 'baz', timeout: 100 }); // // `scope.delay()` can only delay the `response` event. The connection is // still established so it is useless for this test. To work around this // issue a non-routable IP address is used here instead of `nock`. See // https://tools.ietf.org/html/rfc5737#section-3 // return shopifyToken.getAccessToken('192.0.2.1', '123456').then(() => { throw new Error('Test invalidation'); }, (err) => { expect(err).to.be.an.instanceof(Error); expect(err.message).to.equal('Request timed out'); }); }); it('returns an error if response statusCode is not 200', function () { const body = 'some error message from shopify'; scope .post(pathname) .reply(400, body); return shopifyToken.getAccessToken(hostname, '123456').then(() => { throw new Error('Test invalidation'); }, (err) => { expect(err).to.be.an.instanceof(Error); expect(err).to.have.property('message', 'Failed to get Shopify access token'); expect(err).to.have.property('responseBody', body); expect(err).to.have.property('statusCode', 400); }); }); it('returns an error if JSON.parse throws', function () { const body = ''; scope .post(pathname) .reply(200, body); return shopifyToken.getAccessToken(hostname, '123456').then(() => { throw new Error('Test invalidation'); }, (err) => { expect(err).to.be.an.instanceof(Error); expect(err).to.have.property('message', 'Failed to parse the response body'); expect(err).to.have.property('responseBody', body); expect(err).to.have.property('statusCode', 200); }); }); }); }); ================================================ FILE: types/index.d.ts ================================================ /// import { Agent } from 'https'; declare namespace ShopifyToken { export interface ShopifyTokenOptions { // The redirect URL for the Oauth2 flow redirectUri: string; // The Shared Secret for the app sharedSecret: string; // The API Key for the app apiKey: string; // The list of scopes scopes?: string | string[]; // The request timeout timeout?: number; // API access mode accessMode?: string; // The agent used for all HTTP requests agent?: Agent; } export interface OfflineAccessTokenData { access_token: string; scope: string; } export interface AccessTokenAssociatedUser { id: number; first_name: string; last_name: string; email: string; email_verified: boolean; account_owner: boolean; locale: string; collaborator: boolean; } export interface OnlineAccessTokenData { access_token: string; scope: string; expires_in: number; associated_user_scope: string; associated_user: AccessTokenAssociatedUser; } } declare class ShopifyToken { /** * Create a ShopifyToken instance. * * @param {Object} options Configuration options * @param {String} options.redirectUri The redirect URL for the Oauth2 flow * @param {String} options.sharedSecret The Shared Secret for the app * @param {Array|String} [options.scopes] The list of scopes * @param {String} options.apiKey The API Key for the app * @param {String} [options.accessMode] The API access mode * @param {Number} [options.timeout] The request timeout * @param {Agent} [options.agent] The agent used for all HTTP requests */ constructor(options: ShopifyToken.ShopifyTokenOptions); /** * Generate a random nonce. * * @return {String} The random nonce * @public */ generateNonce(): string; /** * Build the authorization URL. * * @param {String} shop The shop name * @param {Array|String} [scopes] The list of scopes * @param {String} [nonce] The nonce * @param {String} [accessMode] The API access mode * @return {String} The authorization URL * @public */ generateAuthUrl( shop: string, scopes?: string | string[], nonce?: string, accessMode?: string ): string; /** * Verify the hmac returned by Shopify. * * @param {Object} query The parsed query string * @return {Boolean} `true` if the hmac is valid, else `false` * @public */ verifyHmac(query: any): boolean; /** * Request an access token. * * @param {String} shop The hostname of the shop, e.g. foo.myshopify.com * @param {String} code The authorization code * @return {Promise} Promise which is fulfilled with an access token and * additional data * @public */ getAccessToken( shop: string, code: string ): Promise< ShopifyToken.OfflineAccessTokenData | ShopifyToken.OnlineAccessTokenData >; } export = ShopifyToken;