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.

794 lines (653 loc) 27.3 kB
// External Imports import { TinyEmitter } from 'tiny-emitter'; import * as jose from 'jose'; // Internal Imports import { Endpoints, AuthScopes, GrantType, type Token, type TokenResponse, type IdToken, type IdTokenClaims, type UserProfile, QuickbooksError, Environment, APIUrls, } from '../../types/types'; import { ApiClient } from '../api/api-client'; /** * 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 { public readonly serializationHeader = 'QBOAUTHTOKEN'; /** * The Auth Header for the application */ public readonly authHeader: string; /** * The Event Emitter for the Auth Provider */ private readonly eventEmitter: TinyEmitter = new TinyEmitter(); /** * Wether to automatically refresh the token when it is expired */ private autoRefresh: boolean = 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) * @param environment The environment to use for the application (optional, defaults to Production) */ constructor( private readonly clientId: string, private readonly clientSecret: string, private readonly redirectUri: string, private readonly scopes: Array<AuthScopes>, private token?: Token, private readonly environment: Environment = Environment.Production, ) { // Generate the Auth Header this.authHeader = 'Basic ' + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); } /** * Enable the Auto Refresh */ public enableAutoRefresh(): void { this.autoRefresh = true; } /** * Disable the Auto Refresh */ public disableAutoRefresh(): void { this.autoRefresh = false; } /** * Get the Access Token * @throws {QuickbooksError} If the token is not provided * @returns {string} The access token */ public async getToken(): Promise<Token> { // Check if the token is expired if (!this.token) throw new QuickbooksError( 'User is not Authorized, please re-authenticate or set the token manually with the setToken method', await ApiClient.getIntuitErrorDetails(null), ); // 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 * @throws {QuickbooksError} If the token is not provided */ public async setToken(newToken: Token | null): Promise<void> { // 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 * @param state The state to use for the OAuth2 URL (optional, auto-generated if not provided) * @param nonce The nonce to use for OpenID Connect (optional, auto-generated if SSO is enabled) * @returns {URL} The OAuth2 URL to get the auth code from the user */ public generateAuthUrl(state: string = crypto.randomUUID(), nonce?: string): URL { // 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); // Add nonce for OpenID Connect if SSO is enabled if (this.isSsoEnabled()) { const nonceValue = nonce ?? crypto.randomUUID(); authUrl.searchParams.set('nonce', nonceValue); } // Return the Auth URL return authUrl; } /** * Exchanges an Auth Code for a Token * @param code The auth code to exchange for a token * @param realmId The realm ID from the OAuth callback * @param nonce Optional nonce to validate the ID token (should match the one used in generateAuthUrl) * @param autoFetchUserProfile Whether to automatically fetch user profile when SSO is enabled (default: true) * @throws {QuickbooksError} If the token is not provided or the refresh token is expired * @returns {Promise<Token>} The token */ public async exchangeCode(code: string, realmId: string, nonce?: string, autoFetchUserProfile: boolean = true): Promise<Token> { // Setup the Request Data const requestData = new URLSearchParams({ redirect_uri: this.redirectUri, code: code, grant_type: GrantType.AuthorizationCode, }); // Setup the Request Options const requestOptions: RequestInit = { 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 QuickbooksError( `Failed to exchange auth code for a token: ${errorMessage}`, await ApiClient.getIntuitErrorDetails(response), ); } // Parse the response const data: TokenResponse = await response.json().catch(async () => { throw new QuickbooksError('Failed to parse the token response', await ApiClient.getIntuitErrorDetails(response)); }); // Clear the Current Token this.token = undefined; // Parse the token response const token = await this.parseTokenResponse(data, realmId, nonce, autoFetchUserProfile); // 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 * @throws {QuickbooksError} If the token is not provided or the refresh token is expired * @returns {Promise<Token>} The refreshed token */ public async refresh(): Promise<Token> { // Check if the token is provided if (!this.token) throw new QuickbooksError( 'Token is not provided, please set the token manually with the setToken method', await ApiClient.getIntuitErrorDetails(null), ); // Check if the refresh token is expired if (this.token.refreshTokenExpiryDate < new Date()) throw new QuickbooksError('Refresh token is expired, please re-authenticate', await ApiClient.getIntuitErrorDetails(null)); // Setup the Request Data const requestData = new URLSearchParams({ refresh_token: this.token.refreshToken, grant_type: GrantType.RefreshToken, }); // Setup the Request Options const requestOptions: RequestInit = { 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 Intuit Error Details const errorDetails = await ApiClient.getIntuitErrorDetails(response); // Throw the Quickbooks Error throw new QuickbooksError(`Failed to refresh token`, errorDetails); } // Parse the response const data: TokenResponse = await response.json().catch(async () => { throw new QuickbooksError('Failed to parse the token response', await ApiClient.getIntuitErrorDetails(response)); }); // Parse the token response const newToken = await 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 * @throws {QuickbooksError} If the token is not provided * @returns {Promise<boolean>} True if the token was revoked, false otherwise */ public async revoke(): Promise<boolean> { // Check if the token is provided if (!this.token) throw new QuickbooksError( 'Token is not provided, please set the token manually with the setToken method', await ApiClient.getIntuitErrorDetails(null), ); // Setup the Request Data const requestData = { token: this.token.refreshToken, }; // Setup the Request Options const requestOptions: RequestInit = { 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 QuickbooksError(`Failed to revoke token: invalid_token`, await ApiClient.getIntuitErrorDetails(response)); // Emit the Revoke Event this.eventEmitter.emit('revoke', this.token); // Clear the Token this.token = undefined; // Return true return true; } /** * Validates the Token * @throws {QuickbooksError} If the token is not provided * @returns {Promise<boolean>} True if the token is valid, false otherwise */ public async validateToken(): Promise<boolean> { // Check if the token is provided if (!this.token) throw new QuickbooksError( 'Token is not provided, please set the token manually with the setToken method', await ApiClient.getIntuitErrorDetails(null), ); // 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 QuickbooksError('Token and Refresh Token are expired, please re-authenticate', await ApiClient.getIntuitErrorDetails(null)); // Refresh the token if it is expired if (tokenExpired && this.autoRefresh) await this.refresh().catch((error: QuickbooksError) => { throw new QuickbooksError(`Failed to refresh token: ${error.message}`, error.details); }); // Check if the token is expired and auto refresh is disabled if (tokenExpired && !this.autoRefresh) throw new QuickbooksError('Token is expired, please refresh the token', await ApiClient.getIntuitErrorDetails(null)); // Return true if the token is valid return true; } /** * Serializes the Token * @param secretKey The secret key to use for the serialization * @throws {QuickbooksError} If the secret key is not provided or the token is not provided * @returns {string | undefined} The serialized token */ public async serializeToken(secretKey: string): Promise<string | undefined> { // Check if the secret key is weak if (secretKey.length < 32) throw new QuickbooksError('Secret key must be at least 32 characters long', await ApiClient.getIntuitErrorDetails(null)); // Check if the token is not provided if (!this.token) throw new QuickbooksError( 'Token is not provided, please set the token manually with the setToken method', await ApiClient.getIntuitErrorDetails(null), ); // 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(async (error: Error) => { // Throw an Error throw new QuickbooksError(`Token serialization failed: ${error.message}`, await ApiClient.getIntuitErrorDetails(null)); }); // 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 * @throws {QuickbooksError} If the serialized token is not valid or the secret key is not provided */ public async deserializeToken(serialized: string, secretKey: string): Promise<void> { // Check if the Serialized String is not Valid if (!serialized.includes(':')) throw new QuickbooksError('Invalid serialized token', await ApiClient.getIntuitErrorDetails(null)); // Split the Serialized String const [headerBase64, combinedBase64] = serialized.split(':'); // Check if the header or combined data is not valid if (!headerBase64 || !combinedBase64) throw new QuickbooksError('Invalid serialized token', await ApiClient.getIntuitErrorDetails(null)); // 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 QuickbooksError('Invalid serialized token', await ApiClient.getIntuitErrorDetails(null)); // 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(async (error: Error) => { // Throw an Error throw new QuickbooksError(`Token deserialization failed: ${error.message}`, await ApiClient.getIntuitErrorDetails(null)); }); // Decode the decrypted data const decoded = new TextDecoder().decode(decrypted); // Parse the decoded token data const parsed = JSON.parse(decoded) as Token; // 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 */ public onRefresh(callback: (refreshedToken: Token) => void): void { // 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 */ public onRevoke(callback: (revokedToken: Token) => void): void { // Add the callback to the list of callbacks this.eventEmitter.on('revoke', callback); } /** * Decodes and verifies an ID token from a JWT string using cryptographic signature verification * @param idTokenString The ID token JWT string * @returns {IdToken} The decoded and verified ID token * @throws {QuickbooksError} If the ID token is invalid, signature verification fails, or cannot be decoded * @remarks This method performs cryptographic signature verification using Intuit's JWKS endpoint. * The token signature, issuer, audience, and expiration are all verified before the token is accepted. * Token claims are cryptographically verified and safe for authorization decisions. */ public async decodeIdToken(idTokenString: string): Promise<IdToken> { try { // Create a remote JWKS set for signature verification const JWKS = jose.createRemoteJWKSet(new URL(APIUrls.JWKS)); // Verify the JWT signature, issuer, audience, and expiration // This performs cryptographic signature verification using the public keys from Intuit's JWKS endpoint const { payload } = await jose.jwtVerify(idTokenString, JWKS, { issuer: APIUrls.Issuer, audience: this.clientId, }); // Return the verified token with claims return { raw: idTokenString, claims: payload as IdTokenClaims }; } catch (error: unknown) { // Setup the Error Message let errorMessage = 'Failed to decode and verify ID token'; // Check if the error is an expired token if (error instanceof jose.errors.JWTExpired) errorMessage = 'ID token has expired'; // Check if the error is an invalid token if (error instanceof jose.errors.JWTInvalid) errorMessage = 'ID token is invalid'; // Check if the error is a signature verification failed if (error instanceof jose.errors.JWSSignatureVerificationFailed) errorMessage = 'ID token signature verification failed - token may be forged or tampered with'; // Check if the error is a claim validation failed if (error instanceof jose.errors.JWTClaimValidationFailed) errorMessage = `ID token claim validation failed: ${error.claim} - ${error.reason}`; // Check if the error is an unknown error if (error instanceof Error) errorMessage = `Failed to decode and verify ID token: ${error.message}`; // Throw the Quickbooks Error throw new QuickbooksError(errorMessage, await ApiClient.getIntuitErrorDetails(null)); } } /** * Validates additional ID token claims (nonce and email verification) * Note: Signature, issuer, audience, and expiration are already validated by decodeIdToken using cryptographic verification * @param idToken The ID token to validate (must already be decoded and signature-verified) * @param nonce Optional nonce to validate against (should match the one used in generateAuthUrl) * @returns {boolean} True if the ID token claims are valid * @throws {QuickbooksError} If the ID token claims are invalid */ public async validateIdToken(idToken: IdToken, nonce?: string): Promise<boolean> { // Get the claims from the ID token const claims = idToken.claims; // Validate nonce if provided // The nonce is used to prevent replay attacks and must match the one sent in the authorization request if (nonce && claims.nonce !== nonce) throw new QuickbooksError('ID token nonce does not match', await ApiClient.getIntuitErrorDetails(null)); // Validate email verification if email is present // This ensures that if an email claim is present, it has been verified by Intuit if (claims.email && !claims.email_verified) throw new QuickbooksError('Email is not verified', await ApiClient.getIntuitErrorDetails(null)); // Return true if all additional validations pass // Note: Signature, issuer, audience, and expiration were already verified in decodeIdToken return true; } /** * Retrieves the user profile from Intuit's user info endpoint * This method requires an access token and is used for SSO flows * @returns {Promise<UserProfile>} The user profile information * @throws {QuickbooksError} If the request fails or the token is not available */ public async getUserProfile(): Promise<UserProfile> { // Get the access token const token = await this.getToken(); // Determine the endpoint based on environment // If not specified, try to detect from ID token issuer, otherwise default to production const userInfoEndpoint = this.environment === Environment.Sandbox ? Endpoints.SandboxUserInfo : Endpoints.ProductionUserInfo; // Setup the Request Options const requestOptions: RequestInit = { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${token.accessToken}`, }, }; // Request the User Profile const response = await fetch(userInfoEndpoint, requestOptions); // Check if the response is successful if (!response.ok) { // Get the error message const errorMessage = await response.text(); // Throw an error throw new QuickbooksError(`Failed to retrieve user profile: ${errorMessage}`, await ApiClient.getIntuitErrorDetails(response)); } // Parse the response const data: UserProfile = await response.json().catch(async () => { throw new QuickbooksError('Failed to parse the user profile response', await ApiClient.getIntuitErrorDetails(response)); }); // Return the user profile return data; } /** * Checks if SSO (OpenID Connect) is enabled * @returns {boolean} True if the openid scope is included */ public isSsoEnabled(): boolean { // Return true if the openid scope is included return this.scopes.includes(AuthScopes.OpenId); } /** * Gets the current user profile if SSO is enabled * This is a convenience method that returns the cached user profile from the token * or fetches it if not available * @returns {Promise<UserProfile | undefined>} The user profile or undefined if SSO is not enabled */ public async getCurrentUserProfile(): Promise<UserProfile | undefined> { // Check if SSO is enabled if (!this.isSsoEnabled()) return undefined; // Check if we have a token with user profile if (this.token?.userProfile) return this.token.userProfile; // Try to get the user profile try { const userProfile = await this.getUserProfile(); // Cache it in the token if we have one if (this.token) this.token.userProfile = userProfile; return userProfile; } catch (error) { // If we can't get the profile, return undefined return undefined; } } /** * 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 */ private async deriveKey(secretKey: string, salt: Uint8Array, keyUsage: 'encrypt' | 'decrypt'): Promise<CryptoKey> { // Encode the Secret Key const keyBuffer = new TextEncoder().encode(secretKey); // Setup the Encryption Algorithm const encryptionAlgorithm: Pbkdf2Params = { name: 'PBKDF2', salt: salt as BufferSource, 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 * @param nonce Optional nonce to validate the ID token * @param autoFetchUserProfile Whether to automatically fetch user profile when SSO is enabled * @returns {Promise<Token>} The parsed token */ private async parseTokenResponse( response: TokenResponse, realmId: string, nonce?: string, autoFetchUserProfile: boolean = true, ): Promise<Token> { // 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: Token = { tokenType: response.token_type, refreshToken: response.refresh_token, refreshTokenExpiryDate: newRefreshTokenExpiryDate, accessToken: response.access_token, accessTokenExpiryDate: accessTokenExpiryDate, realmId: realmId, }; // Check if there is no Id token and return the parsed token if (!response.id_token) return parsedToken; /** * Handle ID Token if present * The flow after this is only to handle SSO related functionality * If you want to use the ID token for other purposes, you can decode it using the decodeIdToken method */ // Decode the ID token const idToken = await this.decodeIdToken(response.id_token).catch(async (error: Error) => { // Log a warning console.warn('Failed to decode ID token:', error); // Do not throw an error, we want to continue with the token exchange even if the ID token is not valid return null; }); // Check if the ID token is not valid and return the parsed token if (!idToken) return parsedToken; // Validate the ID token const isValid = await this.validateIdToken(idToken, nonce).catch(async (error: Error) => { // Log a warning console.warn('Failed to validate ID token:', error); // Do not throw an error, we want to continue with the token exchange even if the ID token is not valid return false; }); // Check if the ID token is not valid and return the parsed token if (!isValid) return parsedToken; // Store the ID token only after validation succeeds parsedToken.idToken = idToken; // Check if Auto Fetch User Profile is disable or SSO is not enabled and return the parsed token if (!autoFetchUserProfile || !this.isSsoEnabled()) return parsedToken; // Temporarily set the token to fetch user profile const tempToken = this.token; // Update the token this.token = parsedToken; // Get the user profile const userProfile = await this.getUserProfile().catch(async (error: Error) => { // Log a warning console.warn('Failed to fetch user profile:', error); // Do not throw an error, we want to continue with the token exchange even if the user profile is not available return null; }); // Check if the user profile is not available and return the parsed token if (!userProfile) { // Restore the token this.token = tempToken; // Return the parsed token return parsedToken; } // Update the token parsedToken.userProfile = userProfile; // Restore the token this.token = tempToken; // Return the parsed token return parsedToken; } /** * Restores the Token Types * @param parsedToken The parsed token to restore * @returns {Token} The restored token */ private restoreTokenTypes(parsedToken: Token): Token { // Create a copy of the parsed token const restored: Token = { ...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 as keyof Token] = new Date(value) as any; } // 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 */ private isDateString(value: string): boolean { // 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()); } }