diff --git a/README.md b/README.md index 1ccf9530..0fec449b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,43 @@ The client provides several custom error types for better error handling: - `ValidationError`: For validation related errors - `TokenError`: For token related errors +### OAuth2 Error Response Handling + +When OAuth operations fail (e.g., `createToken()`, `refresh()`), the library now properly surfaces the full error response from QuickBooks: + +```javascript +try { + const authResponse = await oauthClient.createToken(code); + const token = authResponse.getToken(); +} catch (error) { + // Access detailed OAuth2 error information + console.error('OAuth Error:', error.error); // e.g., "invalid_grant" + console.error('Description:', error.error_description); // e.g., "Token invalid" + console.error('Transaction ID:', error.intuit_tid); // For debugging with QuickBooks support + console.error('Full response:', error.authResponse.json); // Complete error payload + + // Handle specific OAuth2 errors + if (error.error === 'invalid_grant') { + // Authorization code expired or invalid - redirect user to re-authorize + } else if (error.error === 'invalid_client') { + // Client credentials are invalid - check configuration + } +} +``` + +### Error Handling Demo + +Try the interactive error handling demo to see how OAuth2 errors are surfaced: + +```bash +node test/error-handling-demo.js +``` + +This demo script demonstrates: +- How OAuth2 error responses are captured and surfaced +- Full error details including error codes, descriptions, and transaction IDs +- Programmatic error handling based on error types + Example error handling: ```javascript @@ -74,6 +111,38 @@ try { } ``` +### Common OAuth2 Errors + +The library properly handles and surfaces these QuickBooks OAuth2 errors: + +| Error Code | Description | Typical Cause | Recommended Action | +|------------|-------------|---------------|-------------------| +| `invalid_grant` | Authorization grant is invalid, expired, or revoked | User needs to re-authorize | Redirect user to authorization URL | +| `invalid_client` | Client authentication failed | Invalid clientId/clientSecret | Verify OAuth credentials in config | +| `invalid_request` | Request is malformed or invalid | Incorrect request parameters | Check authorization code and redirect URI | +| `unauthorized_client` | Client is not authorized | Missing required permissions | Check app configuration and scopes | +| `unsupported_grant_type` | Grant type not supported | Wrong grant_type parameter | Use 'authorization_code' or 'refresh_token' | + +### Debugging OAuth Errors + +When an OAuth error occurs, use these debugging strategies: + +1. **Log full error details**: + ```javascript + console.log('Error Code:', error.error); + console.log('Description:', error.error_description); + console.log('Transaction ID:', error.intuit_tid); + console.log('Full Response:', JSON.stringify(error.authResponse.json, null, 2)); + ``` + +2. **Use the Transaction ID**: Contact QuickBooks support with the `intuit_tid` from the error for detailed troubleshooting + +3. **Check error code**: Different error codes require different handling strategies (see table above) + +4. **Examine response body**: `error.authResponse.body` contains the raw response string for detailed debugging + +5. **Enable logging**: Set `logging: true` in OAuthClient config to capture detailed logs in `logs/oAuthClient-log.log` + ## Retry Logic The client includes automatic retry logic for transient errors: @@ -790,8 +859,87 @@ oauthClient.createToken(parseRedirect).catch(function (error) { ## FAQ -You can refer to our [FAQ](https://github.com/intuit/oauth-jsclient/wiki/FAQ) if you have any -questions. +### Common Issues + +#### API calls fail after upgrading to version 4.2.1 + +**Problem**: After upgrading from version 4.2.0 to 4.2.1, API calls started failing with malformed header errors. + +**Cause**: Version 4.2.1 had a bug in the `makeApiCall` method where the Authorization header was incorrectly constructed, causing HTTP requests to have invalid headers. + +**Solution**: Upgrade to version 4.2.2 or later, which fixes this issue. The fix ensures the Authorization header is properly set as: +```javascript +Authorization: `Bearer ${access_token}` +``` + +#### How do I enable logging? + +Pass `logging: true` when creating the OAuthClient instance: +```javascript +const oauthClient = new OAuthClient({ + clientId: 'your_client_id', + clientSecret: 'your_client_secret', + environment: 'sandbox', + redirectUri: 'http://localhost:8000/callback', + logging: true // Enable logging +}); +``` +Logs will be stored in `/logs/oAuthClient-log.log` + +#### How do I handle token expiration? + +Access tokens expire after 1 hour (3600 seconds). You can check token validity and refresh: +```javascript +if (!oauthClient.isAccessTokenValid()) { + await oauthClient.refresh(); +} +``` + +#### What's the difference between relative and absolute URLs in makeApiCall? + +You can use either format: +- **Absolute URL**: `https://sandbox-quickbooks.api.intuit.com/v3/company/123/item` +- **Relative URL**: `/v3/company/123/item` (the client will automatically prepend the correct base URL based on your environment) + +#### How do I customize retry behavior? + +Configure the retry settings: +```javascript +OAuthClient.retryConfig = { + maxRetries: 3, + retryDelay: 1000, // milliseconds + retryableStatusCodes: [408, 429, 500, 502, 503, 504], + retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED'] +}; +``` + +#### How do I handle OAuth errors like invalid_grant? + +As of version 4.2.2, the library properly surfaces OAuth2 error details. When an OAuth operation fails, you can access: + +```javascript +try { + const authResponse = await oauthClient.createToken(code); +} catch (error) { + console.log('Error:', error.error); // e.g., "invalid_grant" + console.log('Description:', error.error_description); // e.g., "Token invalid" + console.log('Transaction ID:', error.intuit_tid); // For support + + // Handle specific errors + if (error.error === 'invalid_grant') { + // Redirect user to re-authorize + } +} +``` + +Run the demo to see error handling in action: +```bash +node test/error-handling-demo.js +``` + +See the [Error Handling](#error-handling) section for complete details. + +For more questions, refer to our [FAQ wiki](https://github.com/intuit/oauth-jsclient/wiki/FAQ). ## Contributing diff --git a/src/OAuthClient.js b/src/OAuthClient.js index f2877e81..4ad64066 100644 --- a/src/OAuthClient.js +++ b/src/OAuthClient.js @@ -784,6 +784,11 @@ OAuthClient.prototype.getTokenRequest = function getTokenRequest(request) { 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); } diff --git a/test/OAuthClientTest.js b/test/OAuthClientTest.js index d465a519..1e61a45d 100644 --- a/test/OAuthClientTest.js +++ b/test/OAuthClientTest.js @@ -160,6 +160,47 @@ describe('Tests for OAuthClient', () => { const authResponse = await oauthClient.createToken(parseRedirect); expect(authResponse.getToken().access_token).to.be.equal(expectedAccessToken.access_token); }); + + it('should surface error details when createToken fails with 400 invalid_grant', async () => { + // Clear previous nock mocks + nock.cleanAll(); + + // Mock a 400 error response from QuickBooks + nock('https://oauth.platform.intuit.com') + .post('/oauth2/v1/tokens/bearer') + .reply(400, { + error: 'invalid_grant', + error_description: 'Token invalid' + }, { + 'content-type': 'application/json', + 'intuit_tid': '1234-5678-9012-3456' + }); + + const parseRedirect = 'http://localhost:8000/callback?state=testState&code=invalid_code'; + + try { + await oauthClient.createToken(parseRedirect); + // If we get here, the test should fail + expect.fail('Should have thrown an error'); + } catch (error) { + // Verify that error details are properly surfaced + expect(error).to.have.property('error'); + expect(error.error).to.equal('invalid_grant'); + expect(error).to.have.property('error_description'); + expect(error.error_description).to.equal('Token invalid'); + + // Verify authResponse contains the error details + expect(error).to.have.property('authResponse'); + expect(error.authResponse).to.not.be.empty; + expect(error.authResponse.body).to.not.be.empty; + expect(error.authResponse.json).to.not.be.null; + expect(error.authResponse.json).to.have.property('error', 'invalid_grant'); + expect(error.authResponse.json).to.have.property('error_description', 'Token invalid'); + + // Verify intuit_tid is captured + expect(error.intuit_tid).to.equal('1234-5678-9012-3456'); + } + }); }); // Refresh bearer tokens @@ -214,6 +255,45 @@ describe('Tests for OAuthClient', () => { it('Handle refresh using token with empty token', async () => { await expect(oauthClient.refreshUsingToken(null)).to.be.rejectedWith(Error); }); + + it('should surface error details when refresh fails with 400 invalid_grant', async () => { + // Clear previous nock mocks + nock.cleanAll(); + + // Mock a 400 error response from QuickBooks + nock('https://oauth.platform.intuit.com') + .post('/oauth2/v1/tokens/bearer') + .reply(400, { + error: 'invalid_grant', + error_description: 'Refresh token is invalid or expired' + }, { + 'content-type': 'application/json', + 'intuit_tid': '9876-5432-1098-7654' + }); + + try { + await oauthClient.refresh(); + // If we get here, the test should fail + expect.fail('Should have thrown an error'); + } catch (error) { + // Verify that error details are properly surfaced + expect(error).to.have.property('error'); + expect(error.error).to.equal('invalid_grant'); + expect(error).to.have.property('error_description'); + expect(error.error_description).to.equal('Refresh token is invalid or expired'); + + // Verify authResponse contains the error details + expect(error).to.have.property('authResponse'); + expect(error.authResponse).to.not.be.empty; + expect(error.authResponse.body).to.not.be.empty; + expect(error.authResponse.json).to.not.be.null; + expect(error.authResponse.json).to.have.property('error', 'invalid_grant'); + expect(error.authResponse.json).to.have.property('error_description', 'Refresh token is invalid or expired'); + + // Verify intuit_tid is captured + expect(error.intuit_tid).to.equal('9876-5432-1098-7654'); + } + }); }); // Revoke bearer tokens diff --git a/test/error-handling-demo.js b/test/error-handling-demo.js new file mode 100644 index 00000000..ed902f43 --- /dev/null +++ b/test/error-handling-demo.js @@ -0,0 +1,183 @@ +/** + * Demonstration of the improved error handling in intuit-oauth + * + * This script demonstrates how OAuth2 errors are now properly surfaced + * when token operations fail (e.g., invalid_grant, expired tokens, etc.) + */ + +const OAuthClient = require('../src/OAuthClient'); + +// Create an OAuth client instance +const oauthClient = new OAuthClient({ + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + environment: 'sandbox', + redirectUri: 'http://localhost:3000/callback' +}); + +/** + * Example 1: Handling createToken errors + * + * Before the fix: + * - error.message: "Request failed with status code 400" + * - error.authResponse.body: "" (empty) + * - error.authResponse.json: null (null) + * + * After the fix: + * - error.error: "invalid_grant" + * - error.error_description: "Token invalid" + * - error.authResponse.body: '{"error":"invalid_grant","error_description":"Token invalid"}' + * - error.authResponse.json: { error: "invalid_grant", error_description: "Token invalid" } + * - error.intuit_tid: "1234-5678-9012-3456" + */ +async function demonstrateCreateTokenError() { + try { + const authResponse = await oauthClient.createToken('invalid_code'); + console.log('Token created:', authResponse.getToken()); + } catch (error) { + console.error('Error creating token:'); + console.error(' error:', error.error); + console.error(' error_description:', error.error_description); + console.error(' intuit_tid:', error.intuit_tid); + console.error(' authResponse.body:', error.authResponse.body); + console.error(' authResponse.json:', error.authResponse.json); + + // Now you can handle specific OAuth2 errors + if (error.error === 'invalid_grant') { + console.log('The authorization code is invalid or expired. User needs to re-authorize.'); + } + } +} + +/** + * Example 2: Handling refresh token errors + * + * Similar improvements for refresh() method + */ +async function demonstrateRefreshTokenError() { + try { + const authResponse = await oauthClient.refresh(); + console.log('Token refreshed:', authResponse.getToken()); + } catch (error) { + console.error('Error refreshing token:'); + console.error(' error:', error.error); + console.error(' error_description:', error.error_description); + console.error(' intuit_tid:', error.intuit_tid); + + // Handle specific refresh token errors + if (error.error === 'invalid_grant') { + console.log('The refresh token is invalid or expired. User needs to re-authorize.'); + } + } +} + +/** + * Example 3: Programmatic error handling + * + * You can now build sophisticated error handling based on the actual error type + */ +function handleOAuthError(error) { + // Check if we have OAuth error details + if (error.error && error.error_description) { + switch (error.error) { + case 'invalid_grant': + return { + userMessage: 'Your session has expired. Please sign in again.', + action: 'REAUTHORIZE', + logDetails: { + error: error.error, + description: error.error_description, + transactionId: error.intuit_tid + } + }; + + case 'invalid_client': + return { + userMessage: 'Authentication configuration error. Please contact support.', + action: 'CONTACT_SUPPORT', + logDetails: { + error: error.error, + description: error.error_description, + transactionId: error.intuit_tid + } + }; + + case 'invalid_request': + return { + userMessage: 'Invalid request. Please try again.', + action: 'RETRY', + logDetails: { + error: error.error, + description: error.error_description, + transactionId: error.intuit_tid + } + }; + + default: + return { + userMessage: 'An authentication error occurred. Please try again.', + action: 'RETRY', + logDetails: { + error: error.error, + description: error.error_description, + transactionId: error.intuit_tid + } + }; + } + } + + // Fallback for non-OAuth errors + return { + userMessage: 'An unexpected error occurred.', + action: 'CONTACT_SUPPORT', + logDetails: { message: error.message } + }; +} + +console.log('='.repeat(80)); +console.log('OAuth Error Handling Demonstration'); +console.log('='.repeat(80)); +console.log('\nThis script demonstrates the improved error handling in intuit-oauth'); +console.log('See the code comments for before/after comparisons\n'); + +// Run a test with an invalid authorization code to demonstrate error handling +async function runDemo() { + console.log('Testing createToken with an invalid authorization code...\n'); + + // Simulate a callback URL with an invalid authorization code + const invalidCallbackUrl = 'http://localhost:3000/callback?code=INVALID_CODE_12345&state=testState&realmId=123456789'; + + try { + const authResponse = await oauthClient.createToken(invalidCallbackUrl); + console.log('āœ… Token created successfully (unexpected)'); + console.log('Token:', authResponse.getToken()); + } catch (error) { + console.log('āŒ Error occurred (expected):\n'); + console.log('šŸ“‹ Error Details:'); + console.log(' ā”œā”€ error:', error.error); + console.log(' ā”œā”€ error_description:', error.error_description); + console.log(' ā”œā”€ intuit_tid:', error.intuit_tid); + console.log(' ā”œā”€ authResponse.body:', error.authResponse?.body ? 'Present āœ“' : 'Missing āœ—'); + console.log(' └─ authResponse.json:', error.authResponse?.json ? 'Present āœ“' : 'Missing āœ—'); + + if (error.authResponse?.json) { + console.log('\nšŸ“„ Full Error Response JSON:'); + console.log(JSON.stringify(error.authResponse.json, null, 2)); + } + + // Demonstrate the error handling helper + console.log('\nšŸ”§ Error Handling Result:'); + const handlingResult = handleOAuthError(error); + console.log(JSON.stringify(handlingResult, null, 2)); + } + + console.log('\n' + '='.repeat(80)); + console.log('Demo Complete'); + console.log('='.repeat(80)); +} + +// Run the demo +runDemo().catch(err => { + console.error('Unexpected error running demo:', err); + process.exit(1); +});