feat: add support for oauth2 in cli (#4578)

Co-authored-by: Pooja Belaramani <109731557+poojabela@users.noreply.github.com>
This commit is contained in:
Pooja
2025-05-12 21:37:42 +05:30
committed by GitHub
parent 2171d491a6
commit f58477931f
13 changed files with 456 additions and 9 deletions

8
package-lock.json generated
View File

@@ -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",

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) => {
@@ -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;

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

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;

View File

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

View File

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

View File

@@ -28,5 +28,8 @@
},
"overrides": {
"rollup": "3.29.5"
},
"dependencies": {
"@types/qs": "^6.9.18"
}
}

View File

@@ -31,7 +31,8 @@ module.exports = [
}),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
terser()
]
terser(),
],
external: ['axios', 'qs']
}
];

View File

@@ -1 +1,2 @@
export { addDigestInterceptor } from './digestauth-helper';
export { addDigestInterceptor } from './digestauth-helper';
export { getOAuth2Token } from './oauth2-helper';

View File

@@ -0,0 +1,199 @@
import axios, { AxiosError } from 'axios';
import qs from 'qs';
export interface TokenStore {
saveToken(serviceId: string, account: string, token: any): Promise<boolean>;
getToken(serviceId: string, account: string): Promise<any>;
deleteToken(serviceId: string, account: string): Promise<boolean>;
}
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<string | null> => {
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;
}
};

View File

@@ -1 +1 @@
export { addDigestInterceptor } from './auth';
export { addDigestInterceptor, getOAuth2Token } from './auth';