diff --git a/package-lock.json b/package-lock.json index 8bf023cfd..f6efd03a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8358,6 +8358,11 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==" + }, "node_modules/@types/react": { "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", @@ -30570,6 +30575,9 @@ "name": "@usebruno/requests", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@types/qs": "^6.9.18" + }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index 2d11350eb..7ec7041b5 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -156,6 +156,37 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc delete request.basicAuth; } + if (request?.oauth2?.grantType) { + switch (request.oauth2.grantType) { + case 'password': + request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || ''; + request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || ''; + request.oauth2.username = _interpolate(request.oauth2.username) || ''; + request.oauth2.password = _interpolate(request.oauth2.password) || ''; + request.oauth2.clientId = _interpolate(request.oauth2.clientId) || ''; + request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || ''; + request.oauth2.scope = _interpolate(request.oauth2.scope) || ''; + request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || ''; + request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || ''; + request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || ''; + request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || ''; + break; + case 'client_credentials': + request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || ''; + request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || ''; + request.oauth2.clientId = _interpolate(request.oauth2.clientId) || ''; + request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || ''; + request.oauth2.scope = _interpolate(request.oauth2.scope) || ''; + request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || ''; + request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || ''; + request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || ''; + request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || ''; + break; + default: + break; + } + } + if (request.awsv4config) { request.awsv4config.accessKeyId = _interpolate(request.awsv4config.accessKeyId) || ''; request.awsv4config.secretAccessKey = _interpolate(request.awsv4config.secretAccessKey) || ''; diff --git a/packages/bruno-cli/src/runner/oauth2.js b/packages/bruno-cli/src/runner/oauth2.js new file mode 100644 index 000000000..f5335dc55 --- /dev/null +++ b/packages/bruno-cli/src/runner/oauth2.js @@ -0,0 +1,6 @@ +const { getOAuth2Token } = require('@usebruno/requests'); +const tokenStore = require('./tokenStore'); + +module.exports = { + getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore) +}; \ No newline at end of file diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 4b0a324ef..bd63704a2 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -1,7 +1,7 @@ const { get, each, filter } = require('lodash'); const decomment = require('decomment'); const crypto = require('node:crypto'); -const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection'); +const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection'); const { createFormData } = require('../utils/form-data'); const prepareRequest = (item = {}, collection = {}) => { @@ -16,6 +16,7 @@ const prepareRequest = (item = {}, collection = {}) => { mergeHeaders(collection, request, requestTreePath); mergeScripts(collection, request, requestTreePath, scriptFlow); mergeVars(collection, request, requestTreePath); + mergeAuth(collection, request, requestTreePath); } each(get(request, 'headers', []), (h) => { @@ -73,6 +74,37 @@ const prepareRequest = (item = {}, collection = {}) => { }; } + if (collectionAuth.mode === 'oauth2') { + const grantType = get(collectionAuth, 'oauth2.grantType'); + + if (grantType === 'client_credentials') { + axiosRequest.oauth2 = { + grantType, + accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'), + clientId: get(collectionAuth, 'oauth2.clientId'), + clientSecret: get(collectionAuth, 'oauth2.clientSecret'), + scope: get(collectionAuth, 'oauth2.scope'), + credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'), + tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), + tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey') + }; + } else if (grantType === 'password') { + axiosRequest.oauth2 = { + grantType, + accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'), + username: get(collectionAuth, 'oauth2.username'), + password: get(collectionAuth, 'oauth2.password'), + clientId: get(collectionAuth, 'oauth2.clientId'), + clientSecret: get(collectionAuth, 'oauth2.clientSecret'), + scope: get(collectionAuth, 'oauth2.scope'), + credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'), + tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), + tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey') + }; + } + } if (collectionAuth.mode === 'awsv4') { axiosRequest.awsv4config = { accessKeyId: get(collectionAuth, 'awsv4.accessKeyId'), @@ -169,6 +201,38 @@ const prepareRequest = (item = {}, collection = {}) => { }; } + if (request.auth.mode === 'oauth2') { + const grantType = get(request, 'auth.oauth2.grantType'); + + if (grantType === 'client_credentials') { + axiosRequest.oauth2 = { + grantType, + clientId: get(request, 'auth.oauth2.clientId'), + clientSecret: get(request, 'auth.oauth2.clientSecret'), + scope: get(request, 'auth.oauth2.scope'), + accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'), + tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), + credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'), + tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey') + }; + } else if (grantType === 'password') { + axiosRequest.oauth2 = { + grantType, + username: get(request, 'auth.oauth2.username'), + password: get(request, 'auth.oauth2.password'), + clientId: get(request, 'auth.oauth2.clientId'), + clientSecret: get(request, 'auth.oauth2.clientSecret'), + scope: get(request, 'auth.oauth2.scope'), + accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'), + tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), + credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'), + tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), + tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey') + }; + } + } + if (request.auth.mode === 'apikey') { if (request.auth.apikey?.placement === 'header') { axiosRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 2bf7bc435..cb7eb98b5 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -22,6 +22,7 @@ const path = require('path'); const { parseDataFromResponse } = require('../utils/common'); const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies'); const { createFormData } = require('../utils/form-data'); +const { getOAuth2Token } = require('./oauth2'); const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); const { addDigestInterceptor } = require('@usebruno/requests'); @@ -303,6 +304,33 @@ const runSingleRequest = async function ( } } + // Handle OAuth2 authentication + if (request.oauth2) { + try { + const token = await getOAuth2Token(request.oauth2); + if (token) { + const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2; + + if (tokenPlacement === 'header') { + request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`; + } else if (tokenPlacement === 'url') { + try { + const url = new URL(request.url); + url.searchParams.set(tokenQueryKey, token); + request.url = url.toString(); + } catch (error) { + console.error('Error applying OAuth2 token to URL:', error.message); + } + } + } + } catch (error) { + console.error('OAuth2 token fetch error:', error.message); + } + + // Remove oauth2 config from request to prevent it from being sent + delete request.oauth2; + } + let response, responseTime; try { diff --git a/packages/bruno-cli/src/runner/tokenStore.js b/packages/bruno-cli/src/runner/tokenStore.js new file mode 100644 index 000000000..1bc5c3273 --- /dev/null +++ b/packages/bruno-cli/src/runner/tokenStore.js @@ -0,0 +1,22 @@ +// In-memory token store implementation for OAuth2 tokens +const tokenStore = { + tokens: new Map(), + + // Save a token with optional expiry information + async saveToken(serviceId, account, token) { + this.tokens.set(`${serviceId}:${account}`, token); + return true; + }, + + // Get a token + async getToken(serviceId, account) { + return this.tokens.get(`${serviceId}:${account}`); + }, + + // Delete a token + async deleteToken(serviceId, account) { + return this.tokens.delete(`${serviceId}:${account}`); + } +}; + +module.exports = tokenStore; \ No newline at end of file diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 3616c1f87..649fb2a33 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -310,6 +310,24 @@ const getTreePathFromCollectionToItem = (collection, _item) => { return path; }; +const mergeAuth = (collection, request, requestTreePath) => { + let collectionAuth = collection?.root?.request?.auth || { mode: 'none' }; + let effectiveAuth = collectionAuth; + + for (let i of requestTreePath) { + if (i.type === 'folder') { + const folderAuth = i?.root?.request?.auth; + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveAuth = folderAuth; + } + } + } + + if (request.auth && request.auth.mode === 'inherit') { + request.auth = effectiveAuth; + } +} + const getAllRequestsInFolder = (folderItems = [], recursive = true) => { let requests = []; @@ -461,8 +479,6 @@ const processCollectionItems = async (items = [], currentPath) => { } }; - - module.exports = { createCollectionJsonFromPathname, mergeHeaders, @@ -470,7 +486,8 @@ module.exports = { mergeScripts, findItemInCollection, getTreePathFromCollectionToItem, + createCollectionFromBrunoObject, + mergeAuth, getAllRequestsInFolder, - getAllRequestsAtFolderRoot, - createCollectionFromBrunoObject + getAllRequestsAtFolderRoot } \ No newline at end of file diff --git a/packages/bruno-cli/tests/runner/prepare-request.spec.js b/packages/bruno-cli/tests/runner/prepare-request.spec.js index 4c6e86c69..d532dcff1 100644 --- a/packages/bruno-cli/tests/runner/prepare-request.spec.js +++ b/packages/bruno-cli/tests/runner/prepare-request.spec.js @@ -150,6 +150,72 @@ describe('prepare-request: prepareRequest', () => { }); }); + describe('OAuth2 Authentication', () => { + it('If collection auth is OAuth2 with client credentials grant type', () => { + collection.root.request.auth = { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + scope: 'read write', + credentialsPlacement: 'header', + tokenPlacement: 'header', + tokenHeaderPrefix: 'Bearer', + tokenQueryKey: 'access_token' + } + }; + + const result = prepareRequest(item, collection); + + expect(result.oauth2).toBeDefined(); + expect(result.oauth2.grantType).toBe('client_credentials'); + expect(result.oauth2.accessTokenUrl).toBe('https://auth.example.com/token'); + expect(result.oauth2.clientId).toBe('test_client_id'); + expect(result.oauth2.clientSecret).toBe('test_client_secret'); + expect(result.oauth2.scope).toBe('read write'); + expect(result.oauth2.credentialsPlacement).toBe('header'); + expect(result.oauth2.tokenPlacement).toBe('header'); + expect(result.oauth2.tokenHeaderPrefix).toBe('Bearer'); + expect(result.oauth2.tokenQueryKey).toBe('access_token'); + }); + + it('If collection auth is OAuth2 with password grant type', () => { + collection.root.request.auth = { + mode: 'oauth2', + oauth2: { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + username: 'test_user', + password: 'test_password', + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + scope: 'read write', + credentialsPlacement: 'body', + tokenPlacement: 'url', + tokenHeaderPrefix: 'Bearer', + tokenQueryKey: 'access_token' + } + }; + + const result = prepareRequest(item, collection); + + expect(result.oauth2).toBeDefined(); + expect(result.oauth2.grantType).toBe('password'); + expect(result.oauth2.accessTokenUrl).toBe('https://auth.example.com/token'); + expect(result.oauth2.username).toBe('test_user'); + expect(result.oauth2.password).toBe('test_password'); + expect(result.oauth2.clientId).toBe('test_client_id'); + expect(result.oauth2.clientSecret).toBe('test_client_secret'); + expect(result.oauth2.scope).toBe('read write'); + expect(result.oauth2.credentialsPlacement).toBe('body'); + expect(result.oauth2.tokenPlacement).toBe('url'); + expect(result.oauth2.tokenHeaderPrefix).toBe('Bearer'); + expect(result.oauth2.tokenQueryKey).toBe('access_token'); + }); + }); + describe('AWS v4 Authentication', () => { it('If collection auth is AWS v4', () => { collection.root.request.auth = { @@ -228,6 +294,7 @@ describe('prepare-request: prepareRequest', () => { }; const result = prepareRequest(item, collection); + const expected = { username: 'testUser', password: 'testPass123' diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index f43820549..1b4fc8311 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -28,5 +28,8 @@ }, "overrides": { "rollup": "3.29.5" + }, + "dependencies": { + "@types/qs": "^6.9.18" } } diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js index fa04da640..83422321d 100644 --- a/packages/bruno-requests/rollup.config.js +++ b/packages/bruno-requests/rollup.config.js @@ -31,7 +31,8 @@ module.exports = [ }), commonjs(), typescript({ tsconfig: './tsconfig.json' }), - terser() - ] + terser(), + ], + external: ['axios', 'qs'] } ]; diff --git a/packages/bruno-requests/src/auth/index.ts b/packages/bruno-requests/src/auth/index.ts index cd302427c..082ca796b 100644 --- a/packages/bruno-requests/src/auth/index.ts +++ b/packages/bruno-requests/src/auth/index.ts @@ -1 +1,2 @@ -export { addDigestInterceptor } from './digestauth-helper'; \ No newline at end of file +export { addDigestInterceptor } from './digestauth-helper'; +export { getOAuth2Token } from './oauth2-helper'; \ No newline at end of file diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts new file mode 100644 index 000000000..e73ac7158 --- /dev/null +++ b/packages/bruno-requests/src/auth/oauth2-helper.ts @@ -0,0 +1,199 @@ +import axios, { AxiosError } from 'axios'; +import qs from 'qs'; + +export interface TokenStore { + saveToken(serviceId: string, account: string, token: any): Promise; + getToken(serviceId: string, account: string): Promise; + deleteToken(serviceId: string, account: string): Promise; +} + +export interface OAuth2Config { + grantType: 'client_credentials' | 'password'; + accessTokenUrl: string; + clientId?: string; + clientSecret?: string; + username?: string; + password?: string; + scope?: string; + credentialsPlacement?: 'header' | 'body'; +} + +interface RequestConfig { + headers: { + 'Content-Type': string; + 'Authorization'?: string; + }; +} + +interface ClientCredentialsData { + grant_type: string; + scope: string; + client_id?: string; + client_secret?: string; +} + +interface PasswordGrantData { + grant_type: string; + username: string; + password: string; + scope: string; + client_id?: string; + client_secret?: string; +} + +/** + * Fetches an OAuth2 token using client credentials grant + */ +const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { + const { + accessTokenUrl, + clientId, + clientSecret, + scope, + credentialsPlacement = 'header' + } = oauth2Config; + + if (!accessTokenUrl || !clientId) { + throw new Error('Missing required OAuth2 parameters'); + } + + const data: ClientCredentialsData = { + grant_type: 'client_credentials', + scope: scope || '' + }; + + const config: RequestConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }; + + // Handle credentials placement + if (credentialsPlacement === 'header') { + config.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`; + } else { + // Credentials in body + data.client_id = clientId; + if (clientSecret) { + data.client_secret = clientSecret; + } + } + + try { + const response = await axios.post(accessTokenUrl, qs.stringify(data), config); + return response.data; + } catch (error) { + if (error instanceof Error) { + console.error('CLIENT_CREDENTIALS: Error fetching OAuth2 token:', error.message); + } + throw error; + } +}; + +/** + * Fetches an OAuth2 token using password grant + */ +const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { + const { + accessTokenUrl, + clientId, + clientSecret, + username, + password, + scope, + credentialsPlacement = 'header' + } = oauth2Config; + + if (!accessTokenUrl || !username || !password) { + throw new Error('Missing required OAuth2 parameters for password grant'); + } + + const data: PasswordGrantData = { + grant_type: 'password', + username, + password, + scope: scope || '' + }; + + const config: RequestConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }; + + // Handle credentials placement + if (credentialsPlacement === 'header' && clientId) { + config.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`; + } else if (clientId) { + // Credentials in body + data.client_id = clientId; + if (clientSecret) { + data.client_secret = clientSecret; + } + } + + try { + const response = await axios.post(accessTokenUrl, qs.stringify(data), config); + return response.data; + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.error('PASSWORD_GRANT: Error fetching OAuth2 token:', error.message); + console.error('Status:', error.response.status, 'Response:', error.response.data); + } else if (error instanceof Error) { + console.error('PASSWORD_GRANT: Error fetching OAuth2 token:', error.message); + } + throw error; + } +}; + +/** + * Manages OAuth2 token retrieval and storage + */ +export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore): Promise => { + const { grantType, clientId, accessTokenUrl } = oauth2Config; + + if (!grantType || !accessTokenUrl) { + throw new Error('Missing required OAuth2 parameters: grantType or accessTokenUrl'); + } + + const serviceId = accessTokenUrl; + const account = clientId || oauth2Config.username || 'default'; + + // Check if we already have a token stored + const existingToken = await tokenStore.getToken(serviceId, account); + + if (existingToken) { + // Check if token is expired + if (existingToken.expires_at && existingToken.expires_at > Date.now()) { + return existingToken.access_token; + } + } + + // No valid token found, fetch a new one + try { + let tokenResponse; + + if (grantType === 'client_credentials') { + tokenResponse = await fetchTokenClientCredentials(oauth2Config); + } else if (grantType === 'password') { + tokenResponse = await fetchTokenPassword(oauth2Config); + } else { + throw new Error(`Unsupported grant type: ${grantType}`); + } + + // Calculate expiry time if expires_in is provided + if (tokenResponse.expires_in) { + tokenResponse.expires_at = Date.now() + tokenResponse.expires_in * 1000; + } + + // Store the token + await tokenStore.saveToken(serviceId, account, tokenResponse); + + return tokenResponse.access_token; + } catch (error) { + if (error instanceof Error) { + console.error('Failed to get OAuth2 token:', error.message); + } + return null; + } +}; \ No newline at end of file diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index 19b02f764..5513916c5 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -1 +1 @@ -export { addDigestInterceptor } from './auth'; \ No newline at end of file +export { addDigestInterceptor, getOAuth2Token } from './auth';