From 0bf169562b60c2ec109dc2b8c6bbfb0833687d23 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 21 Jan 2026 12:23:05 +0530 Subject: [PATCH] feat: enhance OAuth2 support in snippet generation (#6592) * feat: enhance OAuth2 support in snippet generation * Updated getAuthHeaders function to handle OAuth2 authentication, including token retrieval and placement. * Added tests for OAuth2 scenarios, ensuring correct Authorization header generation and handling of edge cases. * Improved error handling for access token retrieval from stored credentials. * refactor: standardize comparison operators in getAuthHeaders function * Updated comparison operators in the getAuthHeaders function to use strict equality (===) for improved consistency and reliability in credential checks. * fix: correct block structure in OAuth2 case of getAuthHeaders function * Added missing block structure for the 'oauth2' case in the getAuthHeaders function to ensure proper execution flow and maintain code clarity. * feat: enhance OAuth2 credential retrieval in getAuthHeaders function * Updated getAuthHeaders function to support retrieval of stored OAuth2 credentials based on collection and item context. * Improved access token handling by checking for existing credentials before falling back to default values. * Enhanced test coverage for OAuth2 scenarios to ensure accurate token management and error handling. * fix: preserve tokenHeaderPrefix value in OAuth2 configuration * Updated snippet-generator.spec.js to ensure that the tokenHeaderPrefix from OAuth2 configuration is preserved, allowing for empty string scenarios. * Default to 'Bearer' only if the tokenHeaderPrefix is undefined, enhancing flexibility in token management. * fix: ensure consistent formatting of authorization header in OAuth2 handling * Updated getAuthHeaders function to always trim the final result of the authorization header for consistent formatting. * Adjusted snippet-generator.spec.js to reflect the same trimming logic for the access token, enhancing test reliability. * fix: clarify token placement handling in getAuthHeaders function * Updated comments in the getAuthHeaders function to specify that when tokenPlacement is 'url', no auth headers are added, and that token placement in the URL/query params must be managed separately. * fix: ensure safe handling of OAuth2 credentials in getAuthHeaders function * Updated getAuthHeaders function to default to an empty array when accessing oauth2Credentials, preventing potential errors when no credentials are available. --- .../utils/snippet-generator.js | 2 +- .../utils/snippet-generator.spec.js | 220 ++++++++++++++++++ .../bruno-app/src/utils/codegenerator/auth.js | 71 +++++- 3 files changed, 291 insertions(+), 2 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js index 03c4cd973..f52faf118 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -20,7 +20,7 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false // Add auth headers if needed if (request.auth && request.auth.mode !== 'none') { const collectionAuth = collection?.draft?.root ? get(collection, 'draft.root.request.auth', null) : get(collection, 'root.request.auth', null); - const authHeaders = getAuthHeaders(collectionAuth, request.auth); + const authHeaders = getAuthHeaders(collectionAuth, request.auth, collection, item); headers = [...headers, ...authHeaders]; } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js index e20f35a22..0946ce2bc 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -554,3 +554,223 @@ describe('generateSnippet with edge-case bodies', () => { expect(result).toMatch(/^curl -X POST/); }); }); + +describe('generateSnippet with OAuth2 authentication', () => { + const language = { target: 'shell', client: 'curl' }; + const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock getAuthHeaders to return OAuth2 headers based on the auth config + const authUtils = require('utils/codegenerator/auth'); + authUtils.getAuthHeaders.mockImplementation((collectionRootAuth, requestAuth, collection = null, item = null) => { + if (requestAuth?.mode === 'oauth2') { + const oauth2Config = requestAuth.oauth2 || {}; + const tokenPlacement = oauth2Config.tokenPlacement || 'header'; + // Use the actual value from config, defaulting to 'Bearer' only if undefined + // Empty string should be preserved to test no-prefix scenarios + const tokenHeaderPrefix = oauth2Config.tokenHeaderPrefix !== undefined + ? oauth2Config.tokenHeaderPrefix + : 'Bearer'; + let accessToken = oauth2Config.accessToken || ''; + + // If collection and item are provided, try to look up stored credentials + if (collection && item && collection.oauth2Credentials) { + const grantType = oauth2Config.grantType || ''; + const urlToLookup = grantType === 'implicit' + ? oauth2Config.authorizationUrl || '' + : oauth2Config.accessTokenUrl || ''; + const credentialsId = oauth2Config.credentialsId || 'credentials'; + const collectionUid = collection.uid; + + if (urlToLookup && collectionUid) { + // Look up stored credentials (simplified - assumes URL is already interpolated in test data) + const credentialsData = collection.oauth2Credentials.find( + (creds) => + creds?.url === urlToLookup + && creds?.collectionUid === collectionUid + && creds?.credentialsId === credentialsId + ); + + if (credentialsData?.credentials?.access_token) { + accessToken = credentialsData.credentials.access_token; + } + } + } + + if (tokenPlacement === 'header') { + // Always trim the final result for consistent formatting + const headerValue = tokenHeaderPrefix + ? `${tokenHeaderPrefix} ${accessToken}`.trim() + : accessToken.trim(); + return [ + { + enabled: true, + name: 'Authorization', + value: headerValue + } + ]; + } + } + return []; + }); + }); + + it('should include OAuth2 Bearer token in Authorization header when tokenPlacement is header', () => { + const item = { + uid: 'oauth-req', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: 'Bearer', + accessToken: 'test-access-token-123' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'Bearer test-access-token-123' + }) + ]) + ); + }); + + it('should use custom tokenHeaderPrefix when provided', () => { + const item = { + uid: 'oauth-req-custom', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: 'OAuth', + accessToken: 'custom-token-456' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'OAuth custom-token-456' + }) + ]) + ); + }); + + it('should not include Authorization header when tokenPlacement is url', () => { + const item = { + uid: 'oauth-req-url', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'url', + tokenQueryKey: 'access_token', + accessToken: 'token-in-url' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + const authHeader = harCall.headers.find((h) => h.name === 'Authorization'); + expect(authHeader).toBeUndefined(); + }); + + it('should use placeholder when accessToken is not available', () => { + const item = { + uid: 'oauth-req-placeholder', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: 'Bearer' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'Bearer ' + }) + ]) + ); + }); + + it('should handle empty tokenHeaderPrefix', () => { + const item = { + uid: 'oauth-req-no-prefix', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: '', + accessToken: 'token-without-prefix' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'token-without-prefix' + }) + ]) + ); + }); +}); diff --git a/packages/bruno-app/src/utils/codegenerator/auth.js b/packages/bruno-app/src/utils/codegenerator/auth.js index ad28970eb..eefb82cfe 100644 --- a/packages/bruno-app/src/utils/codegenerator/auth.js +++ b/packages/bruno-app/src/utils/codegenerator/auth.js @@ -1,6 +1,9 @@ import get from 'lodash/get'; +import { find } from 'lodash'; +import { interpolate } from '@usebruno/common'; +import { getAllVariables } from 'utils/collections/index'; -export const getAuthHeaders = (collectionRootAuth, requestAuth) => { +export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = null, item = null) => { // Discovered edge case where code generation fails when you create a collection which has not been saved yet: // Collection auth therefore null, and request inherits from collection, therefore it is also null // TypeError: Cannot read properties of undefined (reading 'mode') @@ -48,6 +51,72 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth) => { ]; } return []; + case 'oauth2': { + const oauth2Config = get(auth, 'oauth2', {}); + const tokenPlacement = get(oauth2Config, 'tokenPlacement', 'header'); + const tokenHeaderPrefix = get(oauth2Config, 'tokenHeaderPrefix', 'Bearer'); + + // Only add header if token placement is 'header' + if (tokenPlacement === 'header') { + // Try to get access token from persisted credentials + let accessToken = ''; + + if (collection && item) { + try { + const grantType = get(oauth2Config, 'grantType', ''); + // For implicit grant type, use authorizationUrl; for others, use accessTokenUrl + const urlToLookup = grantType === 'implicit' + ? get(oauth2Config, 'authorizationUrl', '') + : get(oauth2Config, 'accessTokenUrl', ''); + const credentialsId = get(oauth2Config, 'credentialsId', 'credentials'); + const collectionUid = get(collection, 'uid'); + + if (urlToLookup && collectionUid) { + // Interpolate the URL with variables + const variables = getAllVariables(collection, item); + const interpolatedUrl = interpolate(urlToLookup, variables); + + // Look up stored credentials + const credentialsData = find( + collection?.oauth2Credentials || [], + (creds) => + creds?.url === interpolatedUrl + && creds?.collectionUid === collectionUid + && creds?.credentialsId === credentialsId + ); + + if (credentialsData?.credentials?.access_token) { + accessToken = credentialsData.credentials.access_token; + } + } + } catch (error) { + console.error('Error retrieving OAuth2 access token:', error); + // Fall back to placeholder if lookup fails + } + } + + // Build the authorization header value + // If tokenHeaderPrefix is empty, just use the token + // Otherwise, use the format: "prefix token" + // Always trim the final result for consistent formatting + const headerValue = ( + tokenHeaderPrefix + ? `${tokenHeaderPrefix} ${accessToken}` + : accessToken + ).trim(); + + return [ + { + enabled: true, + name: 'Authorization', + value: headerValue + } + ]; + } + // If tokenPlacement is 'url', this function does not add any auth headers; + // token placement in the URL/query params must be handled elsewhere. + return []; + } default: return []; }