fix(bru-1928): bruno-cli oauth2 updates (#5729)

This commit is contained in:
lohit
2025-10-07 22:38:52 +05:30
committed by GitHub
parent c1853e613b
commit 10739c32c4
11 changed files with 469 additions and 134 deletions

24
package-lock.json generated
View File

@@ -28497,6 +28497,7 @@
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"debug": "^4.4.3",
"decomment": "^0.9.5",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
@@ -29552,6 +29553,23 @@
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-cli/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"packages/bruno-cli/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -29566,6 +29584,12 @@
"node": ">=12"
}
},
"packages/bruno-cli/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"packages/bruno-common": {
"name": "@usebruno/common",
"version": "0.1.0",

View File

@@ -59,6 +59,7 @@
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"debug": "^4.4.3",
"decomment": "^0.9.5",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",

View File

@@ -210,6 +210,10 @@ const builder = async (yargs) => {
type: 'string',
description: 'Tags to exclude from the run'
})
.option('verbose', {
type: 'boolean',
description: 'Allow verbose output for debugging purposes'
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run request.bru --env-file env.bru', 'Run a request with the environment from env.bru file')
@@ -285,7 +289,8 @@ const handler = async function (argv) {
noproxy,
delay,
tags: includeTags,
excludeTags
excludeTags,
verbose
} = argv;
const collectionPath = process.cwd();
@@ -411,6 +416,9 @@ const handler = async function (argv) {
if (noproxy) {
options['noproxy'] = true;
}
if (verbose) {
options['verbose'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));

View File

@@ -17,6 +17,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
// we clone envVars because we don't want to modify the original object
envVariables = cloneDeep(envVariables);
@@ -43,6 +44,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
...envVariables,
...folderVariables,
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
process: {
env: {
@@ -172,6 +174,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
@@ -183,6 +186,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
@@ -190,6 +194,39 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
default:
break;
}
// Interpolate additional parameters for all OAuth2 grant types
if (request.oauth2.additionalParameters) {
// Interpolate authorization parameters
if (Array.isArray(request.oauth2.additionalParameters.authorization)) {
request.oauth2.additionalParameters.authorization.forEach((param) => {
if (param && param.enabled !== false) {
param.name = _interpolate(param.name) || '';
param.value = _interpolate(param.value) || '';
}
});
}
// Interpolate token parameters
if (Array.isArray(request.oauth2.additionalParameters.token)) {
request.oauth2.additionalParameters.token.forEach((param) => {
if (param && param.enabled !== false) {
param.name = _interpolate(param.name) || '';
param.value = _interpolate(param.value) || '';
}
});
}
// Interpolate refresh parameters
if (Array.isArray(request.oauth2.additionalParameters.refresh)) {
request.oauth2.additionalParameters.refresh.forEach((param) => {
if (param && param.enabled !== false) {
param.name = _interpolate(param.name) || '';
param.value = _interpolate(param.value) || '';
}
});
}
}
}
if (request.awsv4config) {

View File

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

View File

@@ -9,6 +9,7 @@ const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollect
const { buildFormUrlEncodedPayload } = require('../utils/form-data');
const path = require('node:path');
const { isLargeFile } = require('../utils/filesystem');
const { getFormattedOauth2Credentials } = require('../utils/oauth2');
const STREAMING_FILE_SIZE_THRESHOLD = 20 * 1024 * 1024; // 20MB
@@ -93,27 +94,37 @@ const prepareRequest = async (item = {}, collection = {}) => {
axiosRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken'),
additionalParameters: get(collectionAuth, 'oauth2.additionalParameters', { authorization: [], token: [], refresh: [] })
};
} else if (grantType === 'password') {
axiosRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
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'),
credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken'),
additionalParameters: get(collectionAuth, 'oauth2.additionalParameters', { authorization: [], token: [], refresh: [] })
};
}
}
@@ -218,29 +229,39 @@ const prepareRequest = async (item = {}, collection = {}) => {
if (grantType === 'client_credentials') {
axiosRequest.oauth2 = {
grantType,
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
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'),
credentialsId: get(request, 'auth.oauth2.credentialsId'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken'),
additionalParameters: get(request, 'auth.oauth2.additionalParameters', { authorization: [], token: [], refresh: [] })
};
} else if (grantType === 'password') {
axiosRequest.oauth2 = {
grantType,
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
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'),
credentialsId: get(request, 'auth.oauth2.credentialsId'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken'),
additionalParameters: get(request, 'auth.oauth2.additionalParameters', { authorization: [], token: [], refresh: [] })
};
}
}
@@ -367,6 +388,7 @@ const prepareRequest = async (item = {}, collection = {}) => {
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;
axiosRequest.requestVariables = request.requestVariables;
axiosRequest.oauth2CredentialVariables = getFormattedOauth2Credentials();
return axiosRequest;
};

View File

@@ -20,11 +20,11 @@ const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies } = 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');
const { getCACertificates } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {

View File

@@ -1,22 +0,0 @@
// 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

@@ -0,0 +1,50 @@
// In-memory credential store implementation for OAuth2 credentials
const tokenStore = {
credentials: {},
// Save credentials
async saveCredential({ url, credentialsId, credentials }) {
if (!this.credentials[credentialsId]) {
this.credentials[credentialsId] = {};
}
this.credentials[credentialsId][url] = credentials;
return true;
},
// Get credentials
async getCredential({ url, credentialsId }) {
return this.credentials[credentialsId]?.[url];
},
// Delete credentials
async deleteCredential({ url, credentialsId }) {
if (this.credentials[credentialsId]?.[url]) {
delete this.credentials[credentialsId][url];
// Clean up empty credentialsId objects
if (Object.keys(this.credentials[credentialsId]).length === 0) {
delete this.credentials[credentialsId];
}
return true;
}
return false;
},
// Get all stored OAuth2 credentials
getAllCredentials() {
const result = [];
for (const [credentialsId, urlMap] of Object.entries(this.credentials)) {
for (const [url, credentials] of Object.entries(urlMap)) {
if (credentials) {
result.push({
url,
credentialsId,
credentials
});
}
}
}
return result;
}
};
module.exports = tokenStore;

View File

@@ -0,0 +1,33 @@
const { getOAuth2Token: _getOAuth2Token } = require('@usebruno/requests');
const tokenStore = require('../store/tokenStore');
const { getOptions } = require('./bru');
/**
* Formats OAuth2 credentials into variables that can be accessed via bru.getOauth2CredentialVar()
* @returns {Object} Formatted OAuth2 credential variables
*/
const getFormattedOauth2Credentials = () => {
const oauth2Credentials = tokenStore.getAllCredentials();
let credentialsVariables = {};
oauth2Credentials.forEach(({ credentialsId, credentials }) => {
if (credentials) {
Object.entries(credentials).forEach(([key, value]) => {
credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value;
});
}
});
return credentialsVariables;
};
const getOAuth2Token = (oauth2Config) => {
let options = getOptions();
let verbose = options?.verbose;
return _getOAuth2Token(oauth2Config, tokenStore, verbose);
};
module.exports = {
getFormattedOauth2Credentials,
getOAuth2Token
};

View File

@@ -1,10 +1,18 @@
import axios, { AxiosError } from 'axios';
import axios, { AxiosRequestConfig, ResponseType } from 'axios';
import qs from 'qs';
import debug from 'debug';
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>;
saveCredential({ url, credentialsId, credentials }: { url: string; credentialsId: string; credentials: any }): Promise<boolean>;
getCredential({ url, credentialsId }: { url: string; credentialsId: string }): Promise<any>;
deleteCredential({ url, credentialsId }: { url: string; credentialsId: string }): Promise<boolean>;
}
export interface AdditionalParameter {
name: string;
value: string;
enabled: boolean;
sendIn: 'headers' | 'queryparams' | 'body';
}
export interface OAuth2Config {
@@ -15,14 +23,25 @@ export interface OAuth2Config {
username?: string;
password?: string;
scope?: string;
credentialsPlacement?: 'header' | 'body';
credentialsPlacement?: 'basic_auth_header' | 'body';
credentialsId?: string;
autoRefreshToken?: boolean;
autoFetchToken?: boolean;
additionalParameters?: {
token?: AdditionalParameter[];
};
}
interface RequestConfig {
interface RequestConfig extends AxiosRequestConfig {
method: string;
url: string;
headers: {
'Content-Type': string;
'Authorization'?: string;
[key: string]: any;
};
data: string;
responseType: ResponseType;
}
interface ClientCredentialsData {
@@ -30,6 +49,7 @@ interface ClientCredentialsData {
scope?: string;
client_id?: string;
client_secret?: string;
[key: string]: any; // For additional parameters
}
interface PasswordGrantData {
@@ -39,8 +59,51 @@ interface PasswordGrantData {
scope?: string;
client_id?: string;
client_secret?: string;
[key: string]: any; // For additional parameters
}
/**
* Apply additional parameters to a request
*/
const applyAdditionalParameters = (requestConfig: RequestConfig, data: any, params: AdditionalParameter[] = []) => {
params.forEach((param) => {
if (!param.enabled || !param.name) {
return;
}
switch (param.sendIn) {
case 'headers':
requestConfig.headers[param.name] = param.value || '';
break;
case 'queryparams':
// For query params, add to URL
try {
const url = new URL(requestConfig.url);
url.searchParams.append(param.name, param.value || '');
requestConfig.url = url.href;
} catch (error) {
throw new Error(`Invalid token URL: ${requestConfig.url}`);
}
break;
case 'body':
// For body, add to data object
data[param.name] = param.value || '';
break;
}
});
};
/**
* Safely parse JSON response data
*/
const safeParseJSONBuffer = (data: any) => {
try {
return JSON.parse(Buffer.isBuffer(data) ? data.toString() : data);
} catch {
return data;
}
};
/**
* Fetches an OAuth2 token using client credentials grant
*/
@@ -50,13 +113,29 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
clientId,
clientSecret,
scope,
credentialsPlacement = 'header'
credentialsPlacement = 'basic_auth_header',
additionalParameters
} = oauth2Config;
if (!accessTokenUrl || !clientId) {
throw new Error('Missing required OAuth2 parameters');
if (!accessTokenUrl) {
throw new Error('Access Token URL is required for OAuth2 client credentials flow');
}
if (!clientId) {
throw new Error('Client ID is required for OAuth2 client credentials flow');
}
const requestConfig: RequestConfig = {
method: 'POST',
url: accessTokenUrl,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
data: '',
responseType: 'arraybuffer'
};
const data: ClientCredentialsData = {
grant_type: 'client_credentials'
};
@@ -65,31 +144,52 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
data.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(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret || '')}`).toString('base64')}`;
} else {
// Credentials in body
data.client_id = clientId;
if (clientSecret) {
data.client_secret = clientSecret;
}
if (credentialsPlacement === 'basic_auth_header') {
requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret!)}`).toString('base64')}`;
}
if (credentialsPlacement !== 'basic_auth_header') {
data.client_id = clientId;
}
if (clientSecret && clientSecret.trim() !== '' && credentialsPlacement !== 'basic_auth_header') {
data.client_secret = clientSecret;
}
if (additionalParameters?.token?.length) {
applyAdditionalParameters(requestConfig, data, additionalParameters.token);
}
requestConfig.data = qs.stringify(data);
debug('oauth2')('> request');
debug('oauth2')(JSON.stringify(requestConfig, null, 2));
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);
const response = await axios(requestConfig);
const parsedData = safeParseJSONBuffer(response.data);
if (parsedData && typeof parsedData === 'object') {
parsedData.created_at = Date.now();
}
throw error;
debug('oauth2')('> response');
debug('oauth2')(JSON.stringify(parsedData, null, 2));
return parsedData;
} catch (err: any) {
if (err?.response) {
debug('oauth2')('< error');
debug('oauth2')(JSON.stringify({
status: err.response.status,
statusText: err.response.statusText,
data: err.response.data ? safeParseJSONBuffer(err.response.data) : null,
headers: err.response.headers
}, null, 2));
} else {
debug('oauth2')('< error');
debug('oauth2')(err.message || err);
}
throw err;
}
};
@@ -104,13 +204,37 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => {
username,
password,
scope,
credentialsPlacement = 'header'
credentialsPlacement = 'basic_auth_header',
additionalParameters
} = oauth2Config;
if (!accessTokenUrl || !username || !password) {
throw new Error('Missing required OAuth2 parameters for password grant');
if (!accessTokenUrl) {
throw new Error('Access Token URL is required for OAuth2 password credentials flow');
}
if (!username) {
throw new Error('Username is required for OAuth2 password credentials flow');
}
if (!password) {
throw new Error('Password is required for OAuth2 password credentials flow');
}
if (!clientId) {
throw new Error('Client ID is required for OAuth2 password credentials flow');
}
const requestConfig: RequestConfig = {
method: 'POST',
url: accessTokenUrl,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
data: '',
responseType: 'arraybuffer'
};
const data: PasswordGrantData = {
grant_type: 'password',
username,
@@ -121,85 +245,149 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => {
data.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(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret || '')}`).toString('base64')}`;
} else if (clientId) {
// Credentials in body
data.client_id = clientId;
if (clientSecret) {
data.client_secret = clientSecret;
}
if (credentialsPlacement === 'basic_auth_header') {
requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret!)}`).toString('base64')}`;
}
if (credentialsPlacement !== 'basic_auth_header') {
data.client_id = clientId;
}
if (clientSecret && clientSecret.trim() !== '' && credentialsPlacement !== 'basic_auth_header') {
data.client_secret = clientSecret;
}
if (additionalParameters?.token?.length) {
applyAdditionalParameters(requestConfig, data, additionalParameters.token);
}
requestConfig.data = qs.stringify(data);
debug('oauth2')('> request');
debug('oauth2')(JSON.stringify(requestConfig, null, 2));
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);
const response = await axios(requestConfig);
const parsedData = safeParseJSONBuffer(response.data);
if (parsedData && typeof parsedData === 'object') {
parsedData.created_at = Date.now();
}
throw error;
debug('oauth2')('< response');
debug('oauth2')(JSON.stringify(parsedData, null, 2));
return parsedData;
} catch (err: any) {
if (err?.response) {
debug('oauth2')('< error');
debug('oauth2')(JSON.stringify({
status: err.response.status,
statusText: err.response.statusText,
data: err.response.data ? safeParseJSONBuffer(err.response.data) : null,
headers: err.response.headers
}, null, 2));
} else {
debug('oauth2')('< error');
debug('oauth2')(err.message || err);
}
throw err;
}
};
/**
* Check if a token is expired
*/
const isTokenExpired = (credentials: any): boolean => {
if (!credentials?.access_token) {
return true;
}
if (!credentials?.expires_in || !credentials.created_at) {
return false; // No expiration info, assume valid
}
const expiryTime = credentials.created_at + credentials.expires_in * 1000;
return Date.now() > expiryTime;
};
/**
* 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');
export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string): Promise<string | null> => {
const {
grantType,
accessTokenUrl,
credentialsId = 'default',
autoFetchToken = true
} = oauth2Config;
if (verbose) {
debug.enable('oauth2');
}
const serviceId = accessTokenUrl;
const account = clientId || oauth2Config.username || 'default';
if (!grantType) {
throw new Error('Grant type is required for OAuth2');
}
// Check if we already have a token stored
const existingToken = await tokenStore.getToken(serviceId, account);
if (!accessTokenUrl) {
throw new Error('Access token URL is required for OAuth2');
}
if (!['client_credentials', 'password'].includes(grantType)) {
throw new Error(`Unsupported grant type: ${grantType}. Supported types: client_credentials, password`);
}
// Check if we already have credentials stored
const existingToken = await tokenStore.getCredential({ url: accessTokenUrl, credentialsId });
if (existingToken) {
// Check if token is expired
if (existingToken.expires_at && existingToken.expires_at > Date.now()) {
if (!isTokenExpired(existingToken)) {
// Token is valid, use it
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}`);
// Token is expired
if (autoFetchToken) {
// Clear expired token and proceed to fetch new token
await tokenStore.deleteCredential({ url: accessTokenUrl, credentialsId });
} else {
// Return expired token if autoFetchToken is disabled
return existingToken.access_token;
}
}
// Calculate expiry time if expires_in is provided
if (tokenResponse.expires_in) {
tokenResponse.expires_at = Date.now() + tokenResponse.expires_in * 1000;
} else {
// No stored credentials
if (!autoFetchToken) {
// Don't fetch token if autoFetchToken is disabled
return null;
}
// 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;
// Otherwise, proceed to fetch new token
}
};
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}`);
}
if (tokenResponse.error) {
throw new Error(JSON.stringify(tokenResponse));
}
if (!tokenResponse || !tokenResponse.access_token) {
throw new Error('No access token received from server');
}
if (tokenResponse.expires_in && tokenResponse.created_at) {
tokenResponse.expires_at = tokenResponse.created_at + tokenResponse.expires_in * 1000;
}
const saved = await tokenStore.saveCredential({ url: accessTokenUrl, credentialsId, credentials: tokenResponse });
if (!saved) {
console.warn('OAuth2: Failed to save token to store, but proceeding with token');
}
return tokenResponse.access_token;
};