UNPKG

quickbooks-api

Version:

A modular TypeScript SDK for seamless integration with Intuit QuickBooks APIs. Provides robust authentication handling and future-ready foundation for accounting, payments, and commerce operations.

418 lines (417 loc) 16.8 kB
// Imports import { Endpoints, GrantType } from '../../types/types.js'; import { EventEmitter } from 'node:events'; /** * The Auth Provider is responsible for handling the OAuth2 flow for the application. * It is responsible for generating the OAuth2 URL and handling the callback. */ export class AuthProvider { clientId; clientSecret; redirectUri; scopes; token; serializationHeader = 'QBOAUTHTOKEN'; /** * The Auth Header for the application */ authHeader; /** * The Event Emitter for the Auth Provider */ eventEmitter = new EventEmitter(); /** * Wether to automatically refresh the token when it is expired */ autoRefresh = true; /** * Initialize the Auth Provider * @param clientId The client ID for the application *Required* * @param clientSecret The client secret for the application *Required* * @param redirectUri The redirect URI for the application *Required* * @param scopes The scopes for the application *Required* * @param token The token for the application (optional) */ constructor(clientId, clientSecret, redirectUri, scopes, token) { this.clientId = clientId; this.clientSecret = clientSecret; this.redirectUri = redirectUri; this.scopes = scopes; this.token = token; // Generate the Auth Header this.authHeader = 'Basic ' + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); } /** * Enable the Auto Refresh */ enableAutoRefresh() { this.autoRefresh = true; } /** * Disable the Auto Refresh */ disableAutoRefresh() { this.autoRefresh = false; } /** * Get the Access Token * @returns {string} The access token */ async getToken() { // Check if the token is expired if (!this.token) throw new Error('User is not Authorized, please re-authenticate or set the token manually with the setToken method'); // Check if the Token is Expired and Refresh it if it is if (this.token.accessTokenExpiryDate < new Date() && this.autoRefresh) await this.refresh(); // Return the Token return this.token; } /** * Set the Token * @param token The token to set */ async setToken(newToken) { // Check if the Token is not provided and clear the token if (!newToken) return (this.token = undefined); // Update the Token this.token = newToken; // Check if the Token is Expired if (newToken.accessTokenExpiryDate < new Date() && this.autoRefresh) await this.refresh(); } /** * Generates the OAuth2 URL to get the auth code from the user * @returns {URL} The OAuth2 URL to get the auth code from the user */ generateAuthUrl(state = crypto.randomUUID()) { // Join the scopes into a string const scopeUriString = this.scopes.join(' '); // Setup the Auth URL const authUrl = new URL(Endpoints.UserAuth); // Set the Query Params authUrl.searchParams.set('client_id', this.clientId); authUrl.searchParams.set('scope', scopeUriString); authUrl.searchParams.set('redirect_uri', this.redirectUri); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('state', state); // Return the Auth URL return authUrl; } /** * Exchanges an Auth Code for a Token * @param authCode The auth code to exchange for a token * @returns {Promise<Token>} The token */ async exchangeCode(code, realmId) { // Setup the Request Data const requestData = new URLSearchParams({ redirect_uri: this.redirectUri, code: code, grant_type: GrantType.AuthorizationCode, }); // Setup the Request Options const requestOptions = { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', Authorization: this.authHeader, }, body: requestData, }; // Request the Refresh Token const response = await fetch(Endpoints.TokenBearer, requestOptions); // Check if the response is successful if (!response.ok) { // Get the error message const errorMessage = await response.text(); // Throw an error throw new Error(`Failed to exchange auth code for a token: ${errorMessage}`); } // Parse the response const data = (await response.json().catch(() => { throw new Error('Failed to parse the token response'); })); // Clear the Current Token this.token = undefined; // Parse the token response const token = this.parseTokenResponse(data, realmId); // Update the Token this.token = token; // Return the token return token; } /** * Exchanges a Refresh Token for a Token * @param refreshToken The refresh token to exchange for a token * @returns {Promise<Token>} The token */ async refresh() { // Check if the token is provided if (!this.token) throw new Error('Token is not provided, please set the token manually with the setToken method'); // Check if the refresh token is expired if (this.token.refreshTokenExpiryDate < new Date()) throw new Error('Refresh token is expired, please re-authenticate'); // Setup the Request Data const requestData = new URLSearchParams({ refresh_token: this.token.refreshToken, grant_type: GrantType.RefreshToken, }); // Setup the Request Options const requestOptions = { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', Authorization: this.authHeader, }, body: requestData, }; // Request the Refresh Token const response = await fetch(Endpoints.TokenBearer, requestOptions); // Check if the response is successful if (!response.ok) { // Get the error message const errorMessage = await response.text().catch(() => ''); // Throw an error throw new Error(`Failed to refresh token: ${errorMessage}`); } // Parse the response const data = (await response.json().catch(() => { throw new Error('Failed to parse the token response'); })); // Parse the token response const newToken = this.parseTokenResponse(data, this.token.realmId); // Update the Token this.token = newToken; // Emit the Refresh Event this.eventEmitter.emit('refresh', newToken); // Return the new token return newToken; } /** * Revokes a Token * @param token The token to revoke * @returns {Promise<boolean>} True if the token was revoked, false otherwise */ async revoke() { // Check if the token is provided if (!this.token) throw new Error('Token is not provided, please set the token manually with the setToken method'); // Setup the Request Data const requestData = { token: this.token.refreshToken, }; // Setup the Request Options const requestOptions = { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: this.authHeader, }, body: JSON.stringify(requestData), }; // Request the Revoke const response = await fetch(Endpoints.TokenRevoke, requestOptions); // Check if the response is successful if (!response.ok) throw new Error(`Failed to revoke token: invalid_token`); // Emit the Revoke Event this.eventEmitter.emit('revoke', this.token); // Clear the Token this.token = undefined; // Return true return true; } /** * Validates the Token * @returns {Promise<boolean>} True if the token is valid, false otherwise */ async validateToken() { // Check if the token is provided if (!this.token) throw new Error('Token is not provided, please set the token manually with the setToken method'); // Check if the token is expired const tokenExpired = this.token.accessTokenExpiryDate < new Date(); // Check if the refresh token is expired const refreshTokenExpired = this.token.refreshTokenExpiryDate < new Date(); // Check if the Token and Refresh Token are expired if (refreshTokenExpired) throw new Error('Token and Refresh Token are expired, please re-authenticate'); // Refresh the token if it is expired if (tokenExpired && this.autoRefresh) await this.refresh().catch((error) => { throw new Error(`Failed to refresh token: ${error.message}`); }); // Check if the token is expired and auto refresh is disabled if (tokenExpired && !this.autoRefresh) throw new Error('Token is expired, please refresh the token'); // Return true if the token is valid return true; } /** * Serializes the Token * @returns {string | undefined} The serialized token */ async serializeToken(secretKey) { // Check if the secret key is weak if (secretKey.length < 32) throw new Error('Secret key must be at least 32 characters long'); // Check if the token is not provided if (!this.token) throw new Error('Token is not provided, please set the token manually with the setToken method'); // Generate a Random Salt const salt = crypto.getRandomValues(new Uint8Array(16)); // Generate a Random IV const iv = crypto.getRandomValues(new Uint8Array(16)); // Encode the Token Data const tokenData = new TextEncoder().encode(JSON.stringify(this.token)); // Get the Crypto Key const cryptoKey = await this.deriveKey(secretKey, salt, 'encrypt'); // Encrypt the Token Data with AES-GCM const encrypted = await crypto.subtle .encrypt({ name: 'AES-GCM', iv: iv, tagLength: 128 }, cryptoKey, tokenData) .catch((error) => { // Throw an Error throw new Error(`Token serialization failed: ${error.message}`); }); // Setup the Combined Array const combined = new Uint8Array([...iv, ...salt, ...new Uint8Array(encrypted)]); // Convert the Header to a Base64 String const headerBase64 = Buffer.from(this.serializationHeader).toString('base64'); // Convert the Combined Array to a Base64 String const combinedBase64 = Buffer.from(combined).toString('base64'); // Return the Serialized Token return `${headerBase64}:${combinedBase64}`; } /** * Deserializes the Token * @param serialized The serialized token to deserialize * @param secretKey The secret key used for decryption */ async deserializeToken(serialized, secretKey) { // Check if the Serialized String is not Valid if (!serialized.includes(':')) throw new Error('Invalid serialized token'); // Split the Serialized String const [headerBase64, combinedBase64] = serialized.split(':'); // Check if the header or combined data is not valid if (!headerBase64 || !combinedBase64) throw new Error('Invalid serialized token'); // Convert the Header to a String const headerString = Buffer.from(headerBase64, 'base64').toString('utf-8'); // Check if the Header is not Valid if (headerString !== this.serializationHeader) throw new Error('Invalid serialized token'); // Convert combined data from base64 const combined = Buffer.from(combinedBase64, 'base64'); // Extract IV (16 bytes), Salt (16 bytes), and ciphertext const iv = combined.subarray(0, 16); const salt = combined.subarray(16, 32); const ciphertext = combined.subarray(32); // Setup the Crypto Key const cryptoKey = await this.deriveKey(secretKey, salt, 'decrypt'); // Decrypt the data const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, cryptoKey, ciphertext).catch((error) => { // Throw an Error throw new Error(`Token deserialization failed: ${error.message}`); }); // Decode the decrypted data const decoded = new TextDecoder().decode(decrypted); // Parse the decoded token data const parsed = JSON.parse(decoded); // Update the Token this.token = this.restoreTokenTypes(parsed); } /** * Adds a callback to be called when the token is refreshed * @param callback The callback to call when the token is refreshed */ onRefresh(callback) { // Add the callback to the list of callbacks this.eventEmitter.on('refresh', callback); } /** * Adds a callback to be called when the token is revoked * @param callback The callback to call when the token is revoked */ onRevoke(callback) { // Add the callback to the list of callbacks this.eventEmitter.on('revoke', callback); } /** * Derives a Crypto Key * @param secretKey The secret key to derive the key from * @param salt The salt to derive the key from * @param keyUsage The key usage for the derived key * @returns {Promise<CryptoKey>} The derived key */ async deriveKey(secretKey, salt, keyUsage) { // Encode the Secret Key const keyBuffer = new TextEncoder().encode(secretKey); // Setup the Encryption Algorithm const encryptionAlgorithm = { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }; // Setup the Key Material const keyMaterial = await crypto.subtle.importKey('raw', keyBuffer, 'PBKDF2', false, ['deriveKey']); // Derive the encryption key const cryptoKey = await crypto.subtle.deriveKey(encryptionAlgorithm, keyMaterial, { name: 'AES-GCM', length: 256 }, // Fixed algorithm specification false, [keyUsage]); // Return the Crypto Key return cryptoKey; } /** * Parses the Token Response * @param response The token response to parse * @param realmId The realm ID for the token * @returns {Token} The parsed token */ parseTokenResponse(response, realmId) { // Calculate the New Expiry Data const newRefreshTokenExpiryDate = new Date(Date.now() + response.x_refresh_token_expires_in * 1000); // Calculate the Expiry Date for the Access Token with a 5 minute buffer const accessTokenExpiryDate = new Date(Date.now() + (response.expires_in - 300) * 1000); // Parse the Token const parsedToken = { tokenType: response.token_type, refreshToken: response.refresh_token, refreshTokenExpiryDate: newRefreshTokenExpiryDate, accessToken: response.access_token, accessTokenExpiryDate: accessTokenExpiryDate, realmId: realmId, }; // Return the parsed token return parsedToken; } /** * Restores the Token Types * @param parsedToken The parsed token to restore * @returns {Token} The restored token */ restoreTokenTypes(parsedToken) { // Create a copy of the parsed token const restored = { ...parsedToken }; // Convert date strings back to Date objects for (const [key, value] of Object.entries(restored)) { // Handle date fields if (typeof value === 'string' && this.isDateString(value)) restored[key] = new Date(value); } // Return the restored token return restored; } /** * Checks if a value is an ISO date string * @param value The value to check * @returns {boolean} True if the value is an ISO date string, false otherwise */ isDateString(value) { // Check if the value is a valid date string const date = new Date(value); // Return true if the value is a valid date string return !isNaN(date.getTime()); } }