diff --git a/package-lock.json b/package-lock.json index 5218ddf05..97af4cf21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index c298273b4..7e1cae76e 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -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", diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 6d4531587..c4a7502ee 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -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`)); diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index 83b9e0881..d0c385743 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -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) { diff --git a/packages/bruno-cli/src/runner/oauth2.js b/packages/bruno-cli/src/runner/oauth2.js deleted file mode 100644 index f5335dc55..000000000 --- a/packages/bruno-cli/src/runner/oauth2.js +++ /dev/null @@ -1,6 +0,0 @@ -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 7e5432d55..c2b1d8b43 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -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; }; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 40f34a910..8b4c77a02 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -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) => { diff --git a/packages/bruno-cli/src/runner/tokenStore.js b/packages/bruno-cli/src/runner/tokenStore.js deleted file mode 100644 index 1bc5c3273..000000000 --- a/packages/bruno-cli/src/runner/tokenStore.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/packages/bruno-cli/src/store/tokenStore.js b/packages/bruno-cli/src/store/tokenStore.js new file mode 100644 index 000000000..9dedcfc54 --- /dev/null +++ b/packages/bruno-cli/src/store/tokenStore.js @@ -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; diff --git a/packages/bruno-cli/src/utils/oauth2.js b/packages/bruno-cli/src/utils/oauth2.js new file mode 100644 index 000000000..ece6d1407 --- /dev/null +++ b/packages/bruno-cli/src/utils/oauth2.js @@ -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 +}; diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts index e681a5aef..a8f499886 100644 --- a/packages/bruno-requests/src/auth/oauth2-helper.ts +++ b/packages/bruno-requests/src/auth/oauth2-helper.ts @@ -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; - getToken(serviceId: string, account: string): Promise; - deleteToken(serviceId: string, account: string): Promise; + saveCredential({ url, credentialsId, credentials }: { url: string; credentialsId: string; credentials: any }): Promise; + getCredential({ url, credentialsId }: { url: string; credentialsId: string }): Promise; + deleteCredential({ url, credentialsId }: { url: string; credentialsId: string }): Promise; +} + +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 => { - 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 => { + 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 } -}; \ No newline at end of file + + 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; +};