intuit-oauth
Version:
Intuit Node.js client for OAuth2.0 and OpenIDConnect
1,008 lines (889 loc) • 29.9 kB
JavaScript
/* 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');
// Move error classes to a separate file
const OAuthError = require('./errors/OAuthError');
const ValidationError = require('./errors/ValidationError');
const TokenError = require('./errors/TokenError');
/**
* @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.includeRefreshTokenHardExpiresIn = !!(
Object.prototype.hasOwnProperty.call(config, 'includeRefreshTokenHardExpiresIn')
&& config.includeRefreshTokenHardExpiresIn === true
);
this.logging = !!(
Object.prototype.hasOwnProperty.call(config, 'logging') && config.logging === true
);
this.logger = null;
this.state = new Csrf();
// Configure Axios instance
this.axiosInstance = axios.create({
validateStatus(status) {
return status >= 200 && status < 500;
},
});
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({
format: 'YYYY-MM-DD HH:mm:ss.SSS Z', // This will include local timezone offset
}),
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.qbo_environment = {
sandbox: 'https://sandbox.qbo.intuit.com/app/',
production: 'https://qbo.intuit.com/app/',
};
OAuthClient.jwks_uri = 'https://oauth.platform.intuit.com/op/v1/jwks';
OAuthClient.scopes = {
Accounting: 'com.intuit.quickbooks.accounting',
Payment: 'com.intuit.quickbooks.payment',
Payroll: 'payroll.compensation.read',
ProjectManagement: 'project-management.project',
Dimensions: 'app-foundations.custom-dimensions.read',
CustomFields: 'app-foundations.custom-field-definitions',
TimeTracking: 'time-tracking.time-entry',
SalesTax: 'indirect-tax.tax-calculation.quickbooks',
Profile: 'profile',
Email: 'email',
Phone: 'phone',
Address: 'address',
OpenId: 'openid',
};
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;
};
OAuthClient.prototype.getEnvironmentURI = function getEnvironmentURI() {
return (this.environment && this.environment === 'production') ? OAuthClient.environment.production : OAuthClient.environment.sandbox;
}
OAuthClient.prototype.getQBOEnvironmentURI = function getQBOEnvironmentURI() {
return (this.environment && this.environment === 'production') ? OAuthClient.qbo_environment.production : OAuthClient.qbo_environment.sandbox;
}
/**
* 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;
};
/**
* Safe JSON stringify that handles circular references
* @param {*} obj - Object to stringify
* @returns {string} JSON string
*/
function safeStringify(obj) {
try {
return JSON.stringify(obj, (key, value) => {
if (key === '_redirectable' || key === '_currentRequest' || key === 'socket') {
return undefined;
}
return value;
});
} catch (e) {
return String(obj);
}
}
/**
* 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');
// Safely parse query string from URI
const queryIndex = uri.indexOf('?');
const queryPart = queryIndex !== -1 ? uri.substring(queryIndex + 1) : uri;
const params = queryString.parse(queryPart);
// Fix: Directly access token object instead of getToken() which returns a copy
this.token.realmId = params.realmId || '';
if ('state' in params) this.token.state = params.state;
const body = {};
if (params.code) {
body.grant_type = 'authorization_code';
body.code = params.code;
body.redirect_uri = params.redirectUri || this.redirectUri;
}
// Log request details for debugging (without sensitive data)
this.log('info', 'Token exchange request:', {
grant_type: body.grant_type,
code_length: body.code ? body.code.length : 0,
redirect_uri: body.redirect_uri,
has_code: !!body.code,
realmId: this.token.realmId,
state: this.token.state,
});
const request = {
url: OAuthClient.tokenEndpoint,
data: body,
method: 'POST',
headers: this._tokenRequestHeaders(),
};
resolve(this.getTokenRequest(request));
})
.then((res) => {
const authResponse = Object.prototype.hasOwnProperty.call(res, 'json') ? res : null;
const json = (authResponse && authResponse.json) || res;
this.token.setToken(json);
this.log('info', 'Create Token response is : ', safeStringify(authResponse && authResponse.json));
return authResponse;
})
.catch((e) => {
this.log('error', 'Create Token () threw an exception : ', safeStringify(e));
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: this._tokenRequestHeaders(),
};
resolve(this.getTokenRequest(request));
})
.then((res) => {
const authResponse = Object.prototype.hasOwnProperty.call(res, 'json') ? res : null;
const json = (authResponse && authResponse.json) || res;
this.token.setToken(json);
this.log('info', 'Refresh Token () response is : ', safeStringify(authResponse && authResponse.json));
return authResponse;
})
.catch((e) => {
this.log('error', 'Refresh Token () threw an exception : ', safeStringify(e));
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: this._tokenRequestHeaders(),
};
resolve(this.getTokenRequest(request));
})
.then((res) => {
const authResponse = Object.prototype.hasOwnProperty.call(res, 'json') ? res : null;
const json = (authResponse && authResponse.json) || res;
this.token.setToken(json);
this.log(
'info',
'Refresh usingToken () response is : ', safeStringify(authResponse && authResponse.json),
);
return authResponse;
})
.catch((e) => {
this.log('error', 'Refresh Token () threw an exception : ', safeStringify(e));
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 = Object.prototype.hasOwnProperty.call(res, 'json') ? res : null;
this.token.clearToken();
this.log('info', 'Revoke Token () response is : ', safeStringify(authResponse && authResponse.json));
return authResponse;
})
.catch((e) => {
this.log('error', 'Revoke Token () threw an exception : ', safeStringify(e));
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 = Object.prototype.hasOwnProperty.call(res, 'json') ? res : null;
this.log(
'info',
'The Get User Info () response is : ', safeStringify(authResponse && authResponse.json),
);
return authResponse;
})
.catch((e) => {
this.log('error', 'Get User Info () threw an exception : ', safeStringify(e));
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 = async function makeApiCall({ url, method, headers: customHeaders, body, params, timeout, responseType }) {
if (!url) {
throw new ValidationError('URL is required for API call');
}
// Determine the full URL - backward compatibility for relative endpoints
let fullUrl = url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
const baseURL = (this.environment && this.environment === 'production')
? OAuthClient.environment.production
: OAuthClient.environment.sandbox;
const endpoint = url.startsWith('/') ? url.slice(1) : url;
fullUrl = baseURL + endpoint;
}
try {
const requestConfig = {
method: method || 'GET',
headers: {
Authorization: `Bearer ${this.getToken().access_token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': OAuthClient.user_agent,
...customHeaders,
},
timeout: timeout || 30000,
responseType: responseType || 'json',
data: body,
params,
};
const response = await this.axiosInstance(fullUrl, requestConfig);
// Handle error status codes (resolved by validateStatus for 200-499)
if (response.status >= 400) {
const { status, data, headers: responseHeaders } = response;
const intuitTid = responseHeaders && responseHeaders.intuit_tid;
if (status === 400 && data && data.Fault) {
const fault = data.Fault;
const faultError = fault.Error && fault.Error[0];
const errorMessage = (faultError && faultError.Message) || 'Bad Request';
const errorCode = (faultError && faultError.code) || '400';
const errorDetail = (faultError && faultError.Detail) || 'Request validation failed';
const faultType = (fault && fault.type) || 'ValidationFault';
const error = new OAuthError(errorMessage, errorCode, errorDetail, intuitTid);
error.faultType = faultType;
error.fault = {
type: faultType,
errors: fault.Error ? fault.Error.map(err => ({
message: err.Message,
detail: err.Detail,
code: err.code,
})) : [],
time: data.time,
};
throw error;
}
if (status === 400) {
throw new OAuthError(
(data && data.error) || 'Bad Request',
'400',
(data && data.error_description) || 'Request validation failed',
intuitTid,
);
}
if (status === 429) {
throw new OAuthError(
'Rate limit exceeded',
'RATE_LIMIT_EXCEEDED',
'Too many requests, please try again later',
intuitTid,
);
}
throw new OAuthError(
(data && data.error) || 'HTTP Error',
status.toString(),
(data && data.error_description) || `HTTP ${status} error`,
intuitTid,
);
}
this.log('info', 'The makeAPICall () response is : ', JSON.stringify(response.data, null, 2));
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
json: response.data,
body: typeof response.data === 'string' ? response.data : JSON.stringify(response.data),
data: response.data,
};
} catch (error) {
if (error instanceof OAuthError) {
throw error;
}
this.log('error', 'API call failed:', {
error: (error.response && error.response.data) || error.message,
status: error.response && error.response.status,
url: fullUrl,
});
// Handle Axios errors with response (status >= 500)
if (error.response) {
const { status, data, headers: responseHeaders } = error.response;
const intuitTid = responseHeaders && responseHeaders.intuit_tid;
throw new OAuthError(
(data && data.error) || error.message || 'Unknown error',
status === 500 ? 'INTERNAL_SERVER_ERROR' : status.toString(),
(data && data.error_description) || 'An error occurred during the API call',
intuitTid,
);
}
if (error.code === 'ECONNABORTED') {
throw new OAuthError(
`Request timeout of ${timeout || 30000}ms exceeded`,
'TIMEOUT_ERROR',
'The request took too long to complete',
);
}
if (error.request) {
throw new OAuthError(
'Connection reset by peer',
'NETWORK_ERROR',
'A network error occurred while making the request',
);
}
throw new OAuthError(
error.message || 'Unknown error',
'OAUTH_ERROR',
'An unexpected error occurred',
);
}
};
/**
* 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') {
throw new Error('Invalid issuer in ID token');
}
// Step 2 : check if the aud field in idToken contains application's clientId
if (!id_token_payload.aud.find((audience) => audience === this.clientId)) {
throw new Error('Invalid audience in ID token');
}
// Step 3 : ensure the timestamp has not elapsed
if (id_token_payload.exp < Date.now() / 1000) {
throw new Error('ID token has expired');
}
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 :', safeStringify(res));
if (res) return true;
return false;
})
.catch((e) => {
this.log('error', 'The validateIdToken () threw an exception : ', safeStringify(e));
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);
if (!key) {
throw new Error(`Key with kid "${kid}" not found in JWKS`);
}
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 : ',
safeStringify(e),
);
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) => {
this.validateResponse(response);
authResponse.processResponse(response);
if (!authResponse.valid()) {
throw new OAuthError(
'Response has an Error',
response.status.toString(),
response.statusText,
response.headers && response.headers.intuit_tid,
);
}
return authResponse;
})
.catch((e) => {
// If axios error has response data, populate authResponse with it
if (e.response && e.response.data) {
authResponse.processResponse(e.response);
}
if (!e.authResponse) {
e = this.createError(e, authResponse);
}
throw e;
});
};
/**
* Validate Token { validates if token object has refresh 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.');
}
return true;
};
// Add retry configuration
OAuthClient.retryConfig = {
maxRetries: 3,
retryDelay: 1000, // 1 second
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED'],
};
OAuthClient.prototype.shouldRetry = function shouldRetry(error, attempt) {
if (attempt >= OAuthClient.retryConfig.maxRetries) {
return false;
}
// Check if it's a retryable status code
if (error.response && OAuthClient.retryConfig.retryableStatusCodes.includes(error.response.status)) {
return true;
}
// Check if it's a retryable network error
if (error.code && OAuthClient.retryConfig.retryableErrors.includes(error.code)) {
return true;
}
return false;
};
/**
* Make HTTP Request using Axios Client
* @param request
* @returns response
*/
OAuthClient.prototype.loadResponse = function loadResponse(request) {
this.currentRequest = request;
let attempt = 0;
const executeRequest = () => axios(request)
.then((response) => {
this.currentRequest = null;
return response;
})
.catch((error) => {
this.currentRequest = null;
if (this.shouldRetry(error, attempt)) {
attempt += 1;
const delay = OAuthClient.retryConfig.retryDelay * (2 ** (attempt - 1));
this.log('warn', `Retrying request (attempt ${attempt}/${OAuthClient.retryConfig.maxRetries})`, {
error: error.message,
delay,
url: request.url,
});
return new Promise((resolve) => {
setTimeout(() => {
resolve(executeRequest());
}, delay);
});
}
throw error;
});
return executeRequest();
};
/**
* Load response from JWK URI
* @param request
* @returns response
*/
OAuthClient.prototype.loadResponseFromJWKsURI = function loadResponseFromJWKsURI(request) {
return axios.get(request).then((response) => response);
};
/**
* Create Error Wrapper
* @param {Error|string} error - Error object or error message
* @param {AuthResponse} authResponse - AuthResponse object
* @returns {Error} error
*/
OAuthClient.prototype.createError = function createError(error, authResponse) {
if (!error) {
return new Error('');
}
const wrappedError = new Error();
wrappedError.error = '';
wrappedError.authResponse = authResponse || '';
wrappedError.intuit_tid = (authResponse && authResponse.getIntuitTid()) || '';
wrappedError.originalMessage = error.message || '';
wrappedError.error_description = '';
if (authResponse) {
if (authResponse.body) {
try {
const body = typeof authResponse.body === 'string' ? JSON.parse(authResponse.body) : authResponse.body;
if (body.error) {
wrappedError.error = body.error;
wrappedError.error_description = body.error_description || '';
wrappedError.message = body.error;
return wrappedError;
}
} catch (e) {
// If parsing fails, use the original body
wrappedError.error = authResponse.body;
wrappedError.error_description = authResponse.body;
wrappedError.message = authResponse.body;
return wrappedError;
}
}
if (authResponse.response && authResponse.response.statusText) {
wrappedError.error = authResponse.response.statusText;
wrappedError.error_description = authResponse.response.statusText;
wrappedError.message = authResponse.response.statusText;
return wrappedError;
}
}
if (error instanceof Error) {
wrappedError.error = error.message;
wrappedError.message = error.message;
} else if (typeof error === 'string') {
wrappedError.error = error;
wrappedError.message = error;
} else {
wrappedError.error = error.toString();
wrappedError.message = error.toString();
}
return wrappedError;
};
/**
* 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');
};
/**
* Build the headers used on token endpoint requests (createToken, refresh,
* refreshUsingToken). The `x-include-refresh-token-hard-expires-in` header is
* only included when the client was constructed with
* `includeRefreshTokenHardExpiresIn: true`, so callers opt in explicitly.
* @returns {object} headers
*/
OAuthClient.prototype._tokenRequestHeaders = function _tokenRequestHeaders() {
const headers = {
Authorization: `Basic ${this.authHeader()}`,
'Content-Type': AuthResponse._urlencodedContentType,
Accept: AuthResponse._jsonContentType,
'User-Agent': OAuthClient.user_agent,
};
if (this.includeRefreshTokenHardExpiresIn) {
headers['x-include-refresh-token-hard-expires-in'] = 'true';
}
return headers;
};
/**
* Log the message
* @param {string} level - Log level
* @param {string} message - Log message
* @param {*} data - Log data
*/
OAuthClient.prototype.log = function log(level, message, data) {
if (!this.logger) {
return;
}
if (typeof data === 'string') {
this.logger.log(level, message + data);
return;
}
const logData = {
timestamp: new Date().toISOString(),
level,
message,
environment: this.environment,
clientId: this.clientId,
};
// Add safe request context if available
if (this.currentRequest) {
logData.request = {
url: this.currentRequest.url,
method: this.currentRequest.method,
headers: { ...this.currentRequest.headers },
};
}
// Add safe data context
if (data) {
try {
logData.data = JSON.parse(safeStringify(data));
} catch (e) {
logData.data = String(data);
}
}
this.logger.log(level, safeStringify(logData));
};
OAuthClient.prototype.validateResponse = function validateResponse(response) {
if (!response) {
throw new ValidationError('Empty response received');
}
if (!response.status) {
throw new ValidationError('Response missing status code');
}
const intuitTid = response.headers && response.headers.intuit_tid;
if (response.status === 429) {
throw new OAuthError(
'Rate limit exceeded',
'RATE_LIMIT_EXCEEDED',
'Too many requests, please try again later',
intuitTid,
);
}
if (response.status === 401) {
throw new TokenError(
'Unauthorized',
'UNAUTHORIZED',
'Invalid or expired access token',
intuitTid,
);
}
if (response.status === 403) {
throw new OAuthError(
'Forbidden',
'FORBIDDEN',
'Insufficient permissions',
intuitTid,
);
}
return true;
};
module.exports = OAuthClient;