UNPKG

intuit-oauth

Version:

Intuit Node.js client for OAuth2.0 and OpenIDConnect

652 lines (580 loc) 19.4 kB
/* eslint-disable no-undef */ /* eslint-disable camelcase */ /** Copyright (c) 2018 Intuit # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. */ /** * @namespace OAuthClient */ 'use strict'; const atob = require('atob'); const Csrf = require('csrf'); const queryString = require('query-string'); const axios = require('axios'); const os = require('os'); const winston = require('winston'); const path = require('path'); const fs = require('fs'); const jwt = require('jsonwebtoken'); const AuthResponse = require('./response/AuthResponse'); const version = require('../package.json'); const Token = require('./access-token/Token'); /** * @constructor * @param {string} config.environment * @param {string} config.appSecret * @param {string} config.appKey * @param {string} [config.cachePrefix] */ function OAuthClient(config) { this.environment = config.environment; this.clientId = config.clientId; this.clientSecret = config.clientSecret; this.redirectUri = config.redirectUri; this.token = new Token(config.token); this.logging = !!( Object.prototype.hasOwnProperty.call(config, 'logging') && config.logging === true ); this.logger = null; this.state = new Csrf(); if (this.logging) { const dir = './logs'; if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } this.logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`), ), transports: [ new winston.transports.File({ filename: path.join(dir, 'oAuthClient-log.log'), }), ], }); } } OAuthClient.cacheId = 'cacheID'; OAuthClient.authorizeEndpoint = 'https://appcenter.intuit.com/connect/oauth2'; OAuthClient.tokenEndpoint = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; OAuthClient.revokeEndpoint = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'; OAuthClient.userinfo_endpoint_production = 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo'; OAuthClient.userinfo_endpoint_sandbox = 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo'; OAuthClient.migrate_sandbox = 'https://developer-sandbox.api.intuit.com/v2/oauth2/tokens/migrate'; OAuthClient.migrate_production = 'https://developer.api.intuit.com/v2/oauth2/tokens/migrate'; OAuthClient.environment = { sandbox: 'https://sandbox-quickbooks.api.intuit.com/', production: 'https://quickbooks.api.intuit.com/', }; OAuthClient.jwks_uri = 'https://oauth.platform.intuit.com/op/v1/jwks'; OAuthClient.scopes = { Accounting: 'com.intuit.quickbooks.accounting', Payment: 'com.intuit.quickbooks.payment', Payroll: 'com.intuit.quickbooks.payroll', TimeTracking: 'com.intuit.quickbooks.payroll.timetracking', Benefits: 'com.intuit.quickbooks.payroll.benefits', Profile: 'profile', Email: 'email', Phone: 'phone', Address: 'address', OpenId: 'openid', Intuit_name: 'intuit_name', }; OAuthClient.user_agent = `Intuit-OAuthClient-JS_${ version.version }_${os.type()}_${os.release()}_${os.platform()}`; OAuthClient.prototype.setAuthorizeURLs = function setAuthorizeURLs(params) { // check if the customURL's are passed correctly if (!params) { throw new Error("Provide the custom authorize URL's"); } OAuthClient.authorizeEndpoint = params.authorizeEndpoint; OAuthClient.tokenEndpoint = params.tokenEndpoint; OAuthClient.revokeEndpoint = params.revokeEndpoint; this.environment === 'sandbox' ? (OAuthClient.userinfo_endpoint_sandbox = params.userInfoEndpoint) : (OAuthClient.userinfo_endpoint_production = params.userInfoEndpoint); return this; }; /** * Redirect User to Authorization Page * * * @param params * @returns {string} authorize Uri */ OAuthClient.prototype.authorizeUri = function authorizeUri(params) { params = params || {}; // check if the scopes is provided if (!params.scope) throw new Error('Provide the scopes'); const authUri = `${OAuthClient.authorizeEndpoint}?${queryString.stringify({ response_type: 'code', redirect_uri: this.redirectUri, client_id: this.clientId, scope: Array.isArray(params.scope) ? params.scope.join(' ') : params.scope, state: params.state || this.state.create(this.state.secretSync()), })}`; this.log('info', 'The Authorize Uri is :', authUri); return authUri; }; /** * Create Token { exchange authorization code for bearer_token } * * * @param {string|Object} uri * @returns {Promise} */ OAuthClient.prototype.createToken = function createToken(uri) { return new Promise((resolve) => { if (!uri) throw new Error('Provide the Uri'); const params = queryString.parse(uri.split('?').reverse()[0]); this.getToken().realmId = params.realmId ? params.realmId : ''; if ('state' in params) this.getToken().state = params.state; const body = {}; if (params.code) { body.grant_type = 'authorization_code'; body.code = params.code; body.redirect_uri = params.redirectUri || this.redirectUri; } const request = { url: OAuthClient.tokenEndpoint, data: body, method: 'POST', headers: { Authorization: `Basic ${this.authHeader()}`, 'Content-Type': AuthResponse._urlencodedContentType, Accept: AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }, }; resolve(this.getTokenRequest(request)); }) .then((res) => { const authResponse = res.hasOwnProperty('json')? res : null; const json = (authResponse && authResponse.json) || res; this.token.setToken(json); this.log('info', 'Create Token response is : ', JSON.stringify(authResponse && authResponse.json, null, 2)); return authResponse; }) .catch((e) => { this.log('error', 'Create Token () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Refresh the access_token * * * @returns {Promise} */ OAuthClient.prototype.refresh = function refresh() { return new Promise((resolve) => { this.validateToken(); const body = {}; body.grant_type = 'refresh_token'; body.refresh_token = this.getToken().refresh_token; const request = { url: OAuthClient.tokenEndpoint, data: body, method: 'POST', headers: { Authorization: `Basic ${this.authHeader()}`, 'Content-Type': AuthResponse._urlencodedContentType, Accept: AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }, }; resolve(this.getTokenRequest(request)); }) .then((res) => { const authResponse = res.hasOwnProperty('json')? res : null; const json = (authResponse && authResponse.json) || res; this.token.setToken(json); this.log('info', 'Refresh Token () response is : ', JSON.stringify(authResponse && authResponse.json, null, 2)); return authResponse; }) .catch((e) => { this.log('error', 'Refresh Token () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Refresh Tokens by passing refresh_token parameter explicitly * * * @param {string} refresh_token * @returns {Promise} */ OAuthClient.prototype.refreshUsingToken = function refreshUsingToken(refresh_token) { return new Promise((resolve) => { if (!refresh_token) throw new Error('The Refresh token is missing'); const body = {}; body.grant_type = 'refresh_token'; body.refresh_token = refresh_token; const request = { url: OAuthClient.tokenEndpoint, data: body, method: 'POST', headers: { Authorization: `Basic ${this.authHeader()}`, 'Content-Type': AuthResponse._urlencodedContentType, Accept: AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }, }; resolve(this.getTokenRequest(request)); }) .then((res) => { const authResponse = res.hasOwnProperty('json')? res : null; const json = (authResponse && authResponse.json) || res; this.token.setToken(json); this.log( 'info', 'Refresh usingToken () response is : ', JSON.stringify(authResponse && authResponse.json, null, 2), ); return authResponse; }) .catch((e) => { this.log('error', 'Refresh Token () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Revoke access_token/refresh_token * * * @param {Object} params.access_token (optional) * @param {Object} params.refresh_token (optional) * @returns {Promise} */ OAuthClient.prototype.revoke = function revoke(params) { return new Promise((resolve) => { params = params || {}; const body = {}; body.token = params.access_token || params.refresh_token || (this.getToken().isAccessTokenValid() ? this.getToken().access_token : this.getToken().refresh_token); const request = { url: OAuthClient.revokeEndpoint, data: body, method: 'POST', headers: { Authorization: `Basic ${this.authHeader()}`, Accept: AuthResponse._jsonContentType, 'Content-Type': AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }, }; resolve(this.getTokenRequest(request)); }) .then((res) => { const authResponse = res.hasOwnProperty('json')? res : null; this.token.clearToken(); this.log('info', 'Revoke Token () response is : ', JSON.stringify(authResponse && authResponse.json, null, 2)); return authResponse; }) .catch((e) => { this.log('error', 'Revoke Token () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Get User Info { Get User Info } * * * @returns {Promise} */ OAuthClient.prototype.getUserInfo = function getUserInfo() { return new Promise((resolve) => { const request = { url: this.environment === 'sandbox' ? OAuthClient.userinfo_endpoint_sandbox : OAuthClient.userinfo_endpoint_production, method: 'GET', headers: { Authorization: `Bearer ${this.token.access_token}`, Accept: AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }, }; resolve(this.getTokenRequest(request)); }) .then((res) => { const authResponse = res.hasOwnProperty('json')? res : null; this.log( 'info', 'The Get User Info () response is : ', JSON.stringify(authResponse && authResponse.json, null, 2), ); return authResponse; }) .catch((e) => { this.log('error', 'Get User Info () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Make API call. Pass the url,method,headers using `params` object * * @param {params} params * @param {string} params.url * @param {string} params.method (optional) default is GET * @param {Object} params.headers (optional) * @param {Object} params.body (optional) * @param {string} params.responseType (optional) default is json - options are json, text, stream, arraybuffer * @returns {Promise} */ OAuthClient.prototype.makeApiCall = function makeApiCall(params) { return new Promise((resolve) => { params = params || {}; const responseType = params.responseType ? params.responseType : 'json'; const baseHeaders = { Authorization: `Bearer ${this.getToken().access_token}`, Accept: AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }; const headers = params.headers && typeof params.headers === 'object' ? Object.assign({}, baseHeaders, params.headers) : Object.assign({}, baseHeaders); const request = { url: params.url, method: params.method || 'GET', headers, responseType, }; params.body && (request.data = params.body); resolve(this.getTokenRequest(request)); }) .then((res) => { const { body, ...authResponse } = res; this.log('info', 'The makeAPICall () response is : ', JSON.stringify(authResponse.json, null, 2)); if(authResponse.json === null && body) { return { ...authResponse, body: body } } return authResponse; }) .catch((e) => { this.log('error', 'Get makeAPICall () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Validate id_token * * * @param {Object} params(optional) * @returns {Promise<AuthResponse>} */ OAuthClient.prototype.validateIdToken = function validateIdToken(params = {}) { return new Promise((resolve) => { if (!this.getToken().id_token) throw new Error('The bearer token does not have id_token'); const id_token = this.getToken().id_token || params.id_token; // Decode ID Token const token_parts = id_token.split('.'); const id_token_header = JSON.parse(atob(token_parts[0])); const id_token_payload = JSON.parse(atob(token_parts[1])); // Step 1 : First check if the issuer is as mentioned in "issuer" if (id_token_payload.iss !== 'https://oauth.platform.intuit.com/op/v1') return false; // Step 2 : check if the aud field in idToken contains application's clientId if (!id_token_payload.aud.find((audience) => audience === this.clientId)) return false; // Step 3 : ensure the timestamp has not elapsed if (id_token_payload.exp < Date.now() / 1000) return false; const request = { url: OAuthClient.jwks_uri, method: 'GET', headers: { Accept: AuthResponse._jsonContentType, 'User-Agent': OAuthClient.user_agent, }, }; return resolve(this.getKeyFromJWKsURI(id_token, id_token_header.kid, request)); }) .then((res) => { this.log('info', 'The validateIdToken () response is :', JSON.stringify(res, null, 2)); if (res) return true; return false; }) .catch((e) => { this.log('error', 'The validateIdToken () threw an exception : ', JSON.stringify(e, null, 2)); throw e; }); }; /** * Get Key from JWKURI * * * @param {string} id_token * @param {string} kid * @param {Object} request * @returns {Promise} */ OAuthClient.prototype.getKeyFromJWKsURI = function getKeyFromJWKsURI(id_token, kid, request) { return new Promise((resolve) => { resolve(this.loadResponse(request)); }) .then((response) => { if (Number(response.status) !== 200) throw new Error('Could not reach JWK endpoint'); // Find the key by KID const key = response.data.keys.find((el) => el.kid === kid); const cert = this.getPublicKey(key.n, key.e); return jwt.verify(id_token, cert); }) .catch((e) => { e = this.createError(e); this.log( 'error', 'The getKeyFromJWKsURI () threw an exception : ', JSON.stringify(e, null, 2), ); throw e; }); }; /** * Get Public Key * * * @param modulus * @param exponent */ OAuthClient.prototype.getPublicKey = function getPublicKey(modulus, exponent) { // eslint-disable-next-line global-require const getPem = require('rsa-pem-from-mod-exp'); const pem = getPem(modulus, exponent); return pem; }; /** * Get Token Request * * * @param {Object} request * @returns {Promise} */ OAuthClient.prototype.getTokenRequest = function getTokenRequest(request) { const authResponse = new AuthResponse({ token: this.token, }); return new Promise((resolve) => { resolve(this.loadResponse(request)); }) .then((response) => { authResponse.processResponse(response); if (!authResponse.valid()) throw new Error('Response has an Error'); return authResponse; }) .catch((e) => { if (!e.authResponse) e = this.createError(e, authResponse); throw e; }); }; /** * Validate Token * * * @returns {boolean} */ OAuthClient.prototype.validateToken = function validateToken() { if (!this.token.refreshToken()) throw new Error('The Refresh token is missing'); if (!this.token.isRefreshTokenValid()) throw new Error('The Refresh token is invalid, please Authorize again.'); }; /** * Make HTTP Request using Axios Client * @param request * @returns response */ OAuthClient.prototype.loadResponse = function loadResponse(request) { return axios(request).then((response) => response); }; /** * Load response from JWK URI * @param request * @returns response */ OAuthClient.prototype.loadResponseFromJWKsURI = function loadResponseFromJWKsURI(request) { return axios.get(request).then((response) => response); }; /** * Wrap the exception with more information * @param {Error|IApiError} e * @param {AuthResponse} authResponse * @return {Error|IApiError} */ OAuthClient.prototype.createError = function createError(e, authResponse) { if (!authResponse || authResponse.body === '') { e.error = (authResponse && authResponse.response.statusText) || e.message || ''; e.authResponse = authResponse || ''; e.intuit_tid = (authResponse && authResponse.headers() && authResponse.headers().intuit_tid) || ''; e.originalMessage = e.message || ''; e.error_description = (authResponse && authResponse.response.statusText) || ''; return e; } e.authResponse = authResponse; e.originalMessage = e.message; e.error = ''; if ('error' in authResponse.getJson()) { e.error = authResponse.getJson().error; } else if (authResponse.response.statusText) { e.error = authResponse.response.statusText; } else if (e.message) { e.error = e.message; } e.error_description = ''; if ('error_description' in authResponse.getJson()) { e.error_description = authResponse.getJson().error_description; } else if (authResponse.response.statusText) { e.error_description = authResponse.response.statusText; } e.intuit_tid = authResponse.headers().intuit_tid; return e; }; /** * isAccessToken Valid () { TTL of access_token } * @returns {boolean} * @private */ OAuthClient.prototype.isAccessTokenValid = function isAccessTokenValid() { return this.token.isAccessTokenValid(); }; /** * GetToken * @returns {Token} */ OAuthClient.prototype.getToken = function getToken() { return this.token; }; /** * Set Token * @param {Object} * @returns {Token} */ OAuthClient.prototype.setToken = function setToken(params) { this.token = new Token(params); return this.token; }; /** * Get AuthHeader * @returns {string} authHeader */ OAuthClient.prototype.authHeader = function authHeader() { const apiKey = `${this.clientId}:${this.clientSecret}`; return typeof btoa === 'function' ? btoa(apiKey) : Buffer.from(apiKey).toString('base64'); }; OAuthClient.prototype.log = function log(level, message, messageData) { if (this.logging) { this.logger.log(level, message + messageData); } }; module.exports = OAuthClient;