Merge branch 'main' into fix/cli-not-following-redirects

This commit is contained in:
sanjai0py
2025-05-13 20:07:29 +05:30
58 changed files with 1700 additions and 560 deletions

View File

@@ -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) || '';

View File

@@ -0,0 +1,6 @@
const { getOAuth2Token } = require('@usebruno/requests');
const tokenStore = require('./tokenStore');
module.exports = {
getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
};

View File

@@ -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) => {
@@ -72,6 +73,76 @@ const prepareRequest = (item = {}, collection = {}) => {
password: get(collectionAuth, 'digest.password')
};
}
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'),
secretAccessKey: get(collectionAuth, 'awsv4.secretAccessKey'),
sessionToken: get(collectionAuth, 'awsv4.sessionToken'),
service: get(collectionAuth, 'awsv4.service'),
region: get(collectionAuth, 'awsv4.region'),
profileName: get(collectionAuth, 'awsv4.profileName')
};
}
if (collectionAuth.mode === 'ntlm') {
axiosRequest.ntlmConfig = {
username: get(collectionAuth, 'ntlm.username'),
password: get(collectionAuth, 'ntlm.password'),
domain: get(collectionAuth, 'ntlm.domain')
};
}
if (collectionAuth.mode === 'wsse') {
const username = get(collectionAuth, 'wsse.username', '');
const password = get(collectionAuth, 'wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('hex');
// Create the password digest using SHA-1 as required for WSSE
const hash = crypto.createHash('sha1');
hash.update(nonce + ts + password);
const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
}
console.log('axiosRequest', axiosRequest);
}
if (request.auth && request.auth.mode !== 'inherit') {
@@ -129,6 +200,56 @@ const prepareRequest = (item = {}, collection = {}) => {
password: get(request, 'auth.digest.password')
};
}
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;
}
if (request.auth.apikey?.placement === 'queryparams') {
if (axiosRequest.url && request.auth.apikey?.key) {
try {
const urlObj = new URL(request.url);
urlObj.searchParams.set(request.auth.apikey?.key, request.auth.apikey?.value);
axiosRequest.url = urlObj.toString();
} catch (error) {
console.error('Invalid URL:', request.url, error);
}
}
}
}
}
request.body = request.body || {};

View File

@@ -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');
@@ -31,8 +32,7 @@ const onConsoleLog = (type, args) => {
};
const runSingleRequest = async function (
filename,
bruJson,
item,
collectionPath,
runtimeVariables,
envVariables,
@@ -43,14 +43,12 @@ const runSingleRequest = async function (
collection,
runSingleRequestByPathname
) {
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
}
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@@ -84,7 +82,7 @@ const runSingleRequest = async function (
if (result?.skipRequest) {
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -98,7 +96,8 @@ const runSingleRequest = async function (
data: null,
responseTime: 0
},
error: 'Request has been skipped from pre-request script',
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
@@ -313,6 +312,33 @@ const runSingleRequest = async function (
requestMaxRedirects = 5; // Default to 5 redirects
}
// 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 {
@@ -370,10 +396,10 @@ const runSingleRequest = async function (
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} else {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -382,13 +408,14 @@ const runSingleRequest = async function (
data: request.data
},
response: {
status: null,
status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
status: 'error',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName,
@@ -400,12 +427,12 @@ const runSingleRequest = async function (
response.responseTime = responseTime;
console.log(
chalk.green(stripExtension(filename)) +
chalk.green(stripExtension(relativeItemPathname)) +
chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
);
// run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res');
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPostResponseVars(
@@ -446,7 +473,7 @@ const runSingleRequest = async function (
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
assertionResults = assertRuntime.runAssertions(
@@ -508,7 +535,7 @@ const runSingleRequest = async function (
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -524,16 +551,17 @@ const runSingleRequest = async function (
responseTime
},
error: null,
status: 'pass',
assertionResults,
testResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
} catch (err) {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: null,
@@ -542,12 +570,13 @@ const runSingleRequest = async function (
data: null
},
response: {
status: null,
status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
status: 'error',
error: err.message,
assertionResults: [],
testResults: []

View File

@@ -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;