diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js new file mode 100644 index 000000000..ecb0976df --- /dev/null +++ b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + label { + font-size: 0.8125rem; + } + .single-line-editor-wrapper { + max-width: 400px; + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + } +`; + +export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js new file mode 100644 index 000000000..4ef704cd7 --- /dev/null +++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js @@ -0,0 +1,86 @@ +import React from 'react'; +import get from 'lodash/get'; +import StyledWrapper from './StyledWrapper'; +import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; +import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index'; +import { updateFolderAuth } from 'providers/ReduxStore/slices/collections'; +import { useDispatch } from 'react-redux'; +import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; +import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; +import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index'; +import AuthMode from '../AuthMode'; + +const grantTypeComponentMap = (collection, folder) => { + const dispatch = useDispatch(); + + const save = () => { + dispatch(saveFolderRoot(collection.uid, folder.uid)); + }; + + let request = get(folder, 'root.request', {}); + const grantType = get(request, 'auth.oauth2.grantType', 'authorization_code'); + + switch (grantType) { + case 'password': + return ; + case 'authorization_code': + return ; + case 'client_credentials': + return ; + default: + return
TBD
; + } +}; + +const Auth = ({ collection, folder }) => { + const dispatch = useDispatch(); + let request = get(folder, 'root.request', {}); + const authMode = get(folder, 'root.request.auth.mode'); + + const handleSave = () => { + dispatch(saveFolderRoot(collection.uid, folder.uid)); + }; + + const getAuthView = () => { + switch (authMode) { + case 'oauth2': { + return ( + <> + + {grantTypeComponentMap(collection, folder)} + + ); + } + case 'none': { + return null; + } + default: + return null; + } + }; + + return ( + +
+ Configures authentication for the entire folder. This applies to all requests using the{' '} + Inherit option in the Auth tab. +
+
+ +
+ {getAuthView()} +
+ +
+
+ ); +}; + +export default Auth; \ No newline at end of file diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js new file mode 100644 index 000000000..2a42257eb --- /dev/null +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .auth-mode-selector { + border: 1px solid ${({ theme }) => theme.colors.border}; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8125rem; + } + + .auth-mode-label { + color: ${({ theme }) => theme.colors.text}; + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js new file mode 100644 index 000000000..e6e48f110 --- /dev/null +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js @@ -0,0 +1,62 @@ +import React, { useRef, forwardRef } from 'react'; +import get from 'lodash/get'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import { useDispatch } from 'react-redux'; +import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections'; +import { humanizeRequestAuthMode } from 'utils/collections'; +import StyledWrapper from './StyledWrapper'; + +const AuthMode = ({ collection, folder }) => { + const dispatch = useDispatch(); + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const authMode = get(folder, 'root.request.auth.mode'); + + const Icon = forwardRef((props, ref) => { + return ( +
+ {humanizeRequestAuthMode(authMode)} +
+ ); + }); + + const onModeChange = (value) => { + dispatch( + updateFolderAuthMode({ + mode: value, + collectionUid: collection.uid, + folderUid: folder.uid + }) + ); + }; + + return ( + +
+ } placement="bottom-end"> +
{ + dropdownTippyRef.current.hide(); + onModeChange('oauth2'); + }} + > + OAuth 2.0 +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('none'); + }} + > + No Auth +
+
+
+
+ ); +}; + +export default AuthMode; diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js index 4e1eba753..f9e34fa33 100644 --- a/packages/bruno-app/src/components/FolderSettings/index.js +++ b/packages/bruno-app/src/components/FolderSettings/index.js @@ -8,7 +8,9 @@ import Tests from './Tests'; import StyledWrapper from './StyledWrapper'; import Vars from './Vars'; import Documentation from './Documentation'; +import Auth from './Auth'; import DotIcon from 'components/Icons/Dot'; +import get from 'lodash/get'; const ContentIndicator = () => { return ( @@ -37,6 +39,9 @@ const FolderSettings = ({ collection, folder }) => { const responseVars = folderRoot?.request?.vars?.res || []; const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length; + const auth = get(folderRoot, 'request.auth.mode'); + const hasAuth = auth && auth !== 'none'; + const setTab = (tab) => { dispatch( updatedFolderSettingsSelectedTab({ @@ -61,6 +66,9 @@ const FolderSettings = ({ collection, folder }) => { case 'vars': { return ; } + case 'auth': { + return ; + } case 'docs': { return ; } @@ -93,6 +101,10 @@ const FolderSettings = ({ collection, folder }) => { Vars {activeVarsCount > 0 && {activeVarsCount}} +
setTab('auth')}> + Auth + {hasAuth && } +
setTab('docs')}> Docs
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js index 62d841d20..bae6e4a0d 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js @@ -2,7 +2,7 @@ import React, { useRef, forwardRef, useState, useEffect } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; -import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal } from '@tabler/icons'; +import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; import SingleLineEditor from 'components/SingleLineEditor'; import { clearOauth2Cache, fetchOauth2Credentials, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions'; @@ -13,7 +13,7 @@ import Oauth2TokenViewer from '../Oauth2TokenViewer/index'; import { cloneDeep, find } from 'lodash'; import { interpolateStringUsingCollectionAndItem } from 'utils/collections/index'; -const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection }) => { +const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const dropdownTippyRef = useRef(); @@ -23,29 +23,33 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu const [showRefreshButton, setShowRefreshButton] = useState(false); const oAuth = get(request, 'auth.oauth2', {}); - const { - callbackUrl, - authorizationUrl, - accessTokenUrl, - clientId, - clientSecret, - scope, - credentialsPlacement, - state, - pkce, - credentialsId, - tokenPlacement, - tokenHeaderPrefix, - tokenQueryKey, - reuseToken, + const { + callbackUrl, + authorizationUrl, + accessTokenUrl, + clientId, + clientSecret, + scope, + credentialsPlacement, + state, + pkce, + credentialsId, + tokenPlacement, + tokenHeaderPrefix, + tokenQueryKey, refreshUrl, - autoRefresh + autoRefreshToken, + autoFetchToken } = oAuth; + const refreshUrlAvailable = refreshUrl?.trim() !== ''; + const isAutoRefreshDisabled = !refreshUrlAvailable; + + const TokenPlacementIcon = forwardRef((props, ref) => { return (
- {tokenPlacement == 'url' ? 'URL' : 'Headers'} + {tokenPlacement == 'url' ? 'URL' : 'Headers'}
); @@ -54,7 +58,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu const CredentialsPlacementIcon = forwardRef((props, ref) => { return (
- {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} + {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
); @@ -67,11 +71,16 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu requestCopy.headers = {}; toggleFetchingToken(true); try { - await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection })); + await dispatch(fetchOauth2Credentials({ + itemUid: item.uid, + request: requestCopy, + collection, + folderUid: folder?.uid || null + })); toggleFetchingToken(false); toast.success('token fetched successfully!'); } - catch(error) { + catch (error) { console.error(error); toggleFetchingToken(false); toast.error('An error occured while fetching token!'); @@ -88,14 +97,14 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu toggleRefreshingToken(false); toast.success('token refreshed successfully!'); } - catch(error) { + catch (error) { console.error(error); toggleRefreshingToken(false); toast.error('An error occured while refreshing token!'); } } - const handleSave = () => {save();}; + const handleSave = () => { save(); }; const handleChange = (key, value) => { dispatch( @@ -118,10 +127,10 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu tokenPlacement, tokenHeaderPrefix, tokenQueryKey, - reuseToken, refreshUrl, - autoRefresh, - [key]: value + autoRefreshToken, + autoFetchToken, + [key]: value, } }) ); @@ -147,7 +156,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu tokenPlacement, tokenHeaderPrefix, tokenQueryKey, - reuseToken, + autoFetchToken, pkce: !Boolean(oAuth?.['pkce']) } }) @@ -165,10 +174,10 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu }); }; - const { uid: collectionUid } = collection; - const interpolatedUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl }); - const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId); - const creds = credentialsData?.credentials || {}; + const { uid: collectionUid } = collection; + const interpolatedUrl = interpolateStringUsingCollectionAndItem({ collection, item, string: accessTokenUrl }); + const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId); + const creds = credentialsData?.credentials || {}; useEffect(() => { // Update visibility whenever credentials change @@ -302,7 +311,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu /> - : + :
@@ -340,24 +349,58 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
-
- - handleChange('autoRefresh', e.target.checked)} - /> - Automatically refresh the token when it expires +
+
+ +
+ Settings
+ {/* Automatically Fetch Token */} +
+ handleChange('autoFetchToken', e.target.checked)} + className="cursor-pointer ml-1" + /> + +
+
+ + + Automatically fetch a new token when you try to access a resource and don't have one. + +
+
+
+ + {/* Auto Refresh Token (With Refresh URL) */} +
+ handleChange('autoRefreshToken', e.target.checked)} + className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`} + disabled={isAutoRefreshDisabled} + /> + +
+
+ + + Automatically refresh your token using the refresh URL when it expires. + +
+
+
{showRefreshButton && ( )}
+
+
+ +
+ Settings +
+ + {/* Automatically Fetch Token */} +
+ handleChange('autoFetchToken', e.target.checked)} + className="cursor-pointer ml-1" + /> + +
+
+ + + Automatically fetch a new token when you try to access a resource and don't have one. + +
+
+
+ + {/* Auto Refresh Token (With Refresh URL) */} +
+ handleChange('autoRefreshToken', e.target.checked)} + className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`} + disabled={isAutoRefreshDisabled} + /> + +
+
+ + + Automatically refresh your token using the refresh URL when it expires. + +
+
+
+
@@ -113,11 +113,10 @@ const ExpiryTimer = ({ expiresIn }) => { return (
{timeLeft > 0 ? `Expires in ${formatExpiryTime(timeLeft)}` : `Expired`}
@@ -138,16 +137,17 @@ const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun }) - {(creds.token_type || creds.scope) ?
{creds.token_type ?
Token Type: {creds.token_type}
: null} - {creds?.scope ?
- Scope: - {creds.scope} + {creds?.scope ?
+ Scope: + + {creds.scope} +
: null}
: null} diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js index 543f7332e..d3c6f739c 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js @@ -2,7 +2,7 @@ import React, { useRef, forwardRef, useState } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; -import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal } from '@tabler/icons'; +import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons'; import SingleLineEditor from 'components/SingleLineEditor'; import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; @@ -35,11 +35,14 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update tokenPlacement, tokenHeaderPrefix, tokenQueryKey, - reuseToken, refreshUrl, - autoRefresh + autoRefreshToken, + autoFetchToken } = oAuth; + const refreshUrlAvailable = refreshUrl?.trim() !== ''; + const isAutoRefreshDisabled = !refreshUrlAvailable; + const handleFetchOauth2Credentials = async () => { let requestCopy = cloneDeep(request); requestCopy.oauth2 = requestCopy?.auth.oauth2; @@ -113,9 +116,9 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update tokenPlacement, tokenHeaderPrefix, tokenQueryKey, - reuseToken, refreshUrl, - autoRefresh, + autoRefreshToken, + autoFetchToken, [key]: value } }) @@ -294,12 +297,58 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update handleChange('autoRefresh', e.target.checked)} + checked={get(request, 'auth.oauth2.autoRefreshToken', false)} + onChange={(e) => handleChange('autoRefreshToken', e.target.checked)} /> Automatically refresh the token when it expires
+
+
+ +
+ Settings +
+ + {/* Automatically Fetch Token */} +
+ handleChange('autoFetchToken', e.target.checked)} + className="cursor-pointer ml-1" + /> + +
+
+ + + Automatically fetch a new token when you try to access a resource and don't have one. + +
+
+
+ + {/* Auto Refresh Token (With Refresh URL) */} +
+ handleChange('autoRefreshToken', e.target.checked)} + className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`} + disabled={isAutoRefreshDisabled} + /> + +
+
+ + + Automatically refresh your token using the refresh URL when it expires. + +
+
+
+
+ + {showNetworkLogs && ( + + )} +
+ + {/* Tab Content */} +
+ {/* Request Tab */} + {activeTab === 'request' && ( +
+ {/* Method and URL */} +
+ + {request.method.toUpperCase()} + {' '} + {request.url} +
+ + {/* Headers */} +
+
setIsRequestHeadersOpen(!isRequestHeadersOpen)}> + + {isRequestHeadersOpen ? '▼' : '▶'} Headers + {filteredRequestHeaders && Object.keys(filteredRequestHeaders).length > 0 && + ({Object.keys(filteredRequestHeaders).length}) + } + +
+ {isRequestHeadersOpen && ( +
+ {filteredRequestHeaders && Object.keys(filteredRequestHeaders).length > 0 + ? renderHeaders(filteredRequestHeaders) + :
No Headers found
+ } +
+ )} +
+ + {/* Cookies */} +
+
setIsRequestCookiesOpen(!isRequestCookiesOpen)}> + + {isRequestCookiesOpen ? '▼' : '▶'} Cookies + {requestCookies && Object.keys(requestCookies).length > 0 && + ({Object.keys(requestCookies).length}) + } + +
+ {isRequestCookiesOpen && ( +
+ {requestCookies && Object.keys(requestCookies).length > 0 + ? renderHeaders(requestCookies) + :
No Cookies found
+ } +
+ )} +
+ + {/* Body */} +
+
setIsRequestBodyOpen(!isRequestBodyOpen)}> + + {isRequestBodyOpen ? '▼' : '▶'} Body + +
+ {isRequestBodyOpen && ( +
+ {request.data || request.dataBuffer ? ( +
+ +
+ ) : ( +
No Body found
+ )} +
+ )} +
+
+ )} + + {/* Response Tab */} + {activeTab === 'response' && ( +
+ {/* Status */} +
+ + {response.status || request.statusCode} + {' '} + {response.statusText || request.statusText || ''} + {response.duration && {response.duration}ms} + {response.size && {response.size}B} +
+ + {/* Headers */} +
+
setIsResponseHeadersOpen(!isResponseHeadersOpen)}> + + {isResponseHeadersOpen ? '▼' : '▶'} Headers + {filteredResponseHeaders && Object.keys(filteredResponseHeaders).length > 0 && + ({Object.keys(filteredResponseHeaders).length}) + } + +
+ {isResponseHeadersOpen && ( +
+ {filteredResponseHeaders && Object.keys(filteredResponseHeaders).length > 0 + ? renderHeaders(filteredResponseHeaders) + :
No Headers found
+ } +
+ )} +
+ + {/* Cookies */} +
+
setIsResponseCookiesOpen(!isResponseCookiesOpen)}> + + {isResponseCookiesOpen ? '▼' : '▶'} Cookies + {responseCookies && Object.keys(responseCookies).length > 0 && + ({Object.keys(responseCookies).length}) + } + +
+ {isResponseCookiesOpen && ( +
+ {responseCookies && Object.keys(responseCookies).length > 0 + ? renderHeaders(responseCookies) + :
No Cookies found
+ } +
+ )} +
+ + {/* Body */} +
+
setIsResponseBodyOpen(!isResponseBodyOpen)}> + + {isResponseBodyOpen ? '▼' : '▶'} Body + +
+ {isResponseBodyOpen && ( +
+ {response.data || response.dataBuffer ? ( +
+ +
+ ) : ( +
No Body found
+ )} +
+ )} +
+
+ )} + + {/* Network Logs Tab */} + {activeTab === 'networkLogs' && showNetworkLogs && ( +
+
+              {response.timeline.map((entry, index) => (
+                
+              ))}
+            
+
+ )} +
+
+ ); +}; + +const NetworkLogsEntry = ({ entry }) => { + const { type, message } = entry; + let className = ''; + + switch (type) { + case 'request': + className = 'text-blue-500'; + break; + case 'response': + className = 'text-green-500'; + break; + case 'error': + className = 'text-red-500'; + break; + case 'tls': + className = 'text-purple-500'; + break; + case 'info': + className = 'text-yellow-500'; + break; + default: + className = 'text-gray-400'; + break; + } + + return ( +
+
{message}
+
+ ); +}; + +// Helper functions +const renderHeaders = (data) => { + if (Array.isArray(data)) { + return ( +
+ {data.map((header, index) => ( +
+
{header.name}:
+
{String(header.value)}
+
+ ))} +
+ ); + } else { + return ( +
+ {Object.entries(data).map(([key, value], index) => ( +
+
{key}:
+
{String(value)}
+
+ ))} +
+ ); + } +}; + +const separateCookiesAndHeaders = (headers) => { + const cookies = {}; + let filteredHeaders = {}; + + if (Array.isArray(headers)) { + filteredHeaders = headers.filter((header) => header.enabled !== false); + filteredHeaders.forEach((header) => { + if ( + header.name.toLowerCase() === 'cookie' || + header.name.toLowerCase() === 'set-cookie' + ) { + cookies[header.name] = header.value; + } + }); + } else { + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === 'cookie' || key.toLowerCase() === 'set-cookie') { + cookies[key] = value; + } else { + filteredHeaders[key] = value; + } + } + } + + return { cookies, headers: filteredHeaders }; +}; + +const OAuthRequestItem = ({ request, item, collection, width }) => { + const [isOpen, setIsOpen] = useState(false); + const toggleOpen = () => { + setIsOpen((prev) => !prev); + }; + + const { isSubRequest } = request; + const url = request.url || ''; + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']; + const isImage = imageExtensions.some((ext) => url.toLowerCase().endsWith(ext)); + + return ( +
+
+
+
+
+ + {request.method?.toUpperCase()} + {' '} +
+ + {request.statusCode} + {request.statusText || ''} + +
+ {isSubRequest && API Request} + {isImage && Image} + {request.duration && {request.duration}ms} + {request.size && {request.size}B} + {request.state && {request.state}} +
+
+
+
{request.url}
+
+ {isOpen && ( +
+ +
+ )} +
+ ); +}; + +export default Timeline; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index f0df42e3e..91d40ed7d 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -54,7 +54,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { return ; } case 'timeline': { - return ; + return ; } case 'tests': { return ; diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js index 8fd8de9d9..0cd2e986e 100644 --- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -7,10 +7,10 @@ import ResponseHeaders from 'components/ResponsePane/ResponseHeaders'; import StatusCode from 'components/ResponsePane/StatusCode'; import ResponseTime from 'components/ResponsePane/ResponseTime'; import ResponseSize from 'components/ResponsePane/ResponseSize'; -import Timeline from 'components/ResponsePane/Timeline'; import TestResults from 'components/ResponsePane/TestResults'; import TestResultsLabel from 'components/ResponsePane/TestResultsLabel'; import StyledWrapper from './StyledWrapper'; +import RunnerTimeline from 'components/ResponsePane/RunnerTimeline'; const ResponsePane = ({ rightPaneWidth, item, collection }) => { const [selectedTab, setSelectedTab] = useState('response'); @@ -45,7 +45,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { return ; } case 'timeline': { - return ; + return ; } case 'tests': { return ; diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index fecfe69b4..1828a5890 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -164,7 +164,13 @@ const useIpcEvents = () => { }); const removeCollectionOauth2CredentialsUpdatesListener = ipcRenderer.on('main:credentials-update', (val) => { - dispatch(collectionAddOauth2CredentialsByUrl(val)); + const payload = { + ...val, + itemUid: val.itemUid || null, + folderUid: val.folderUid || null, + credentialsId: val.credentialsId || 'credentials' + }; + dispatch(collectionAddOauth2CredentialsByUrl(payload)); }); return () => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 01618bfd2..d11da5fc8 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -238,11 +238,20 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid); sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) .then((response) => { + // Ensure any timestamps in the response are converted to numbers + const serializedResponse = { + ...response, + timeline: response.timeline?.map(entry => ({ + ...entry, + timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp + })) + }; + return dispatch( responseReceived({ itemUid: item.uid, collectionUid: collectionUid, - response: response + response: serializedResponse }) ); }) @@ -1195,39 +1204,61 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS }); }; -export const fetchOauth2Credentials = (payload) => async (dispatch, getState) => { - const { request, collection, itemUid } = payload; - return new Promise((resolve, reject) => { - ipcRenderer - .invoke('renderer:fetch-oauth2-credentials', { itemUid, request, collection }) - .then(({ credentials, url, collectionUid, credentialsId }) => { - dispatch(collectionAddOauth2CredentialsByUrl({ credentials, url, collectionUid, credentialsId })); - resolve(credentials); - }) - .catch(reject); - }) -} - -export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => { - const { request, collection } = payload; - return new Promise((resolve, reject) => { - ipcRenderer - .invoke('renderer:refresh-oauth2-credentials', { request, collection }) - .then(({ credentials, url, collectionUid, credentialsId }) => { - dispatch(collectionAddOauth2CredentialsByUrl({ credentials, url, collectionUid, credentialsId })); - resolve(credentials); - }) - .catch(reject); - }) -} - -export const clearOauth2Cache = (payload) => async (dispatch, getState) => { - const { collectionUid, url, credentialsId } = payload; - return new Promise((resolve, reject) => { - const { ipcRenderer } = window; - ipcRenderer.invoke('clear-oauth2-cache', collectionUid, url, credentialsId).then(() => { - dispatch(collectionClearOauth2CredentialsByUrl({ collectionUid, url, credentialsId })); - resolve(); - }).catch(reject); - }); -}; \ No newline at end of file + export const fetchOauth2Credentials = (payload) => async (dispatch, getState) => { + const { request, collection, itemUid, folderUid } = payload; + return new Promise((resolve, reject) => { + window.ipcRenderer + .invoke('renderer:fetch-oauth2-credentials', { itemUid, request, collection }) + .then(({ credentials, url, collectionUid, credentialsId, debugInfo }) => { + dispatch( + collectionAddOauth2CredentialsByUrl({ + credentials, + url, + collectionUid, + credentialsId, + debugInfo, + folderUid: folderUid || null, + itemUid: !folderUid ? itemUid : null + }) + ); + resolve(credentials); + }) + .catch(reject); + }); + }; + + export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => { + const { request, collection, folderUid, itemId } = payload; + return new Promise((resolve, reject) => { + window.ipcRenderer + .invoke('renderer:refresh-oauth2-credentials', { request, collection }) + .then(({ credentials, url, collectionUid, debugInfo }) => { + dispatch( + collectionAddOauth2CredentialsByUrl({ + credentials, + url, + collectionUid, + debugInfo, + folderUid: folderUid || null, + itemId: !folderUid ? itemId : null + }) + ); + resolve(credentials); + }) + .catch(reject); + }); + }; + + export const clearOauth2Cache = (payload) => async (dispatch, getState) => { + const { collectionUid, url, credentialsId } = payload; + return new Promise((resolve, reject) => { + window.ipcRenderer + .invoke('clear-oauth2-cache', collectionUid, url, credentialsId) + .then(() => { + // We do not dispatch any action to modify the Redux store, + // since we are only clearing the session on the main process side. + resolve(); + }) + .catch(reject); + }); + }; \ No newline at end of file diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 643b0d18a..afe76e776 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -260,13 +260,36 @@ export const collectionsSlice = createSlice({ }, responseReceived: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); - + if (collection) { const item = findItemInCollection(collection, action.payload.itemUid); if (item) { item.requestState = 'received'; item.response = action.payload.response; item.cancelTokenUid = null; + + if (!collection.timeline) { + collection.timeline = []; + } + + // Ensure timestamp is a number (milliseconds since epoch) + const timestamp = item?.requestSent?.timestamp instanceof Date + ? item.requestSent.timestamp.getTime() + : item?.requestSent?.timestamp || Date.now(); + + // Append the new timeline entry with numeric timestamp + collection.timeline.push({ + type: "request", + collectionUid: collection.uid, + folderUid: null, + itemUid: item.uid, + timestamp: timestamp, + data: { + request: item.requestSent || item.request, + response: action.payload.response, + timestamp: timestamp, + } + }); } } }, @@ -1444,6 +1467,26 @@ export const collectionsSlice = createSlice({ set(folder, 'root.request.tests', action.payload.tests); } }, + updateFolderAuth: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + console.log('action.payload.content inside bro', action.payload); + if (!collection) return; + + const folder = collection ? findItemInCollection(collection, action.payload.itemUid) : null; + console.log('folder inside bro', folder); + if (!folder) return; + + if (folder) { + set(folder, 'root.request.auth', {}); + set(folder, 'root.request.auth.mode', action.payload.mode); + switch (action.payload.mode) { + case 'oauth2': + set(folder, 'root.request.auth.oauth2', action.payload.content); + break; + } + } + }, addCollectionHeader: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -1886,34 +1929,83 @@ export const collectionsSlice = createSlice({ } }, collectionAddOauth2CredentialsByUrl: (state, action) => { - const { collectionUid, url, credentials, credentialsId } = action.payload; + const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (!collection) return; - if (!collection?.oauth2Credentials) { + + // Update oauth2Credentials (latest token) + if (!collection.oauth2Credentials) { collection.oauth2Credentials = []; } - let collectionOauth2Credentials = cloneDeep(collection?.oauth2Credentials); - const filterdOauth2Credentials = filter(collectionOauth2Credentials, creds => !(creds?.url == url && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId)); - filterdOauth2Credentials.push({ collectionUid, url, credentials, credentialsId }); - collection.oauth2Credentials = filterdOauth2Credentials; + let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials); + + // Remove existing credentials for the same combination + const filteredOauth2Credentials = filter( + collectionOauth2Credentials, + (creds) => + !(creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId) + ); + + // Add the new credential with folderUid and itemUid + filteredOauth2Credentials.push({ + collectionUid, + folderUid, + itemUid, + url, + credentials, + credentialsId, + debugInfo + }); + + collection.oauth2Credentials = filteredOauth2Credentials; + + if (!collection.timeline) { + collection.timeline = []; + } + + if(debugInfo) { + collection.timeline.push({ + type: "oauth2", + collectionUid, + folderUid, + itemUid, + timestamp: Date.now(), + data: { + collectionUid, + folderUid, + itemUid, + url, + credentials, + credentialsId, + debugInfo: debugInfo.data, + } + }); + } }, + collectionClearOauth2CredentialsByUrl: (state, action) => { - const { collectionUid, url, credentialsId } = action.payload; - const collection = findCollectionByUid(state.collections, collectionUid); - if (!collection) return; - if (!collection?.oauth2Credentials) { - collection.oauth2Credentials = []; - } - let collectionOauth2Credentials = cloneDeep(collection?.oauth2Credentials); - const filterdOauth2Credentials = filter(collectionOauth2Credentials, creds => !(creds?.url == url && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId)); - collection.oauth2Credentials = filterdOauth2Credentials; + // Since we don't want to remove tokens from oauth2Credentials or timeline, }, + collectionGetOauth2CredentialsByUrl: (state, action) => { const { collectionUid, url, credentialsId } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); - const oauth2Credentials = find(collection?.oauth2Credentials || [], creds => (creds?.url == url && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId)); - return oauth2Credentials; - } + const oauth2Credential = find( + collection?.oauth2Credentials || [], + (creds) => + creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId + ); + return oauth2Credential; + }, + updateFolderAuthMode: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; + + if (folder) { + set(folder, 'root.request.auth', {}); + set(folder, 'root.request.auth.mode', action.payload.mode); + } + }, } }); @@ -2016,7 +2108,9 @@ export const { updateFolderDocs, collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByUrl, - collectionGetOauth2CredentialsByUrl + collectionGetOauth2CredentialsByUrl, + updateFolderAuth, + updateFolderAuthMode } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index f6d366dee..423afc82c 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -367,7 +367,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), - reuseToken: get(si.request, 'auth.oauth2.reuseToken', false) + autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), + autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), }; break; case 'authorization_code': @@ -386,7 +387,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), - reuseToken: get(si.request, 'auth.oauth2.reuseToken', false) + autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), + autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), }; break; case 'client_credentials': @@ -402,7 +404,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), - reuseToken: get(si.request, 'auth.oauth2.reuseToken', false) + autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), + autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), }; break; } @@ -1050,9 +1053,11 @@ export const getEnvVars = (environment = {}) => { export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => { let credentialsVariables = {}; oauth2Credentials.forEach(({ credentialsId, credentials }) => { - Object.entries(credentials).forEach(([key, value]) => { - credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value; - }); + if (credentials) { + Object.entries(credentials).forEach(([key, value]) => { + credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value; + }); + } }); return credentialsVariables; -} \ No newline at end of file +}; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 810a327bd..0061d0dff 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -14,7 +14,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV size: response.size, status: response.status, statusText: response.statusText, - duration: response.duration + duration: response.duration, + timeline: response.timeline }); }) .catch((err) => reject(err)); diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index 7fe43218a..6b7d016fc 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -51,6 +51,7 @@ const jsonToCollectionBru = (json, isFolder) => { res: _.get(json, 'request.vars.res', []) }, tests: _.get(json, 'request.tests', ''), + auth: _.get(json, 'request.auth', {}), docs: _.get(json, 'docs', '') }; @@ -63,10 +64,6 @@ const jsonToCollectionBru = (json, isFolder) => { }; } - if (!isFolder) { - collectionBruJson.auth = _.get(json, 'request.auth', {}); - } - return _jsonToCollectionBru(collectionBruJson); } catch (error) { return Promise.reject(error); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index fc0b6cb83..4dc7b9a07 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -808,7 +808,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - requestCopy = await configureRequestWithCertsAndProxy({ + const {newRequest} = await configureRequestWithCertsAndProxy({ collectionUid, request: requestCopy, envVars, @@ -816,23 +816,24 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection processEnvVars, collectionPath }); + requestCopy = newRequest const { oauth2: { grantType }} = requestCopy || {}; let credentials, url, credentialsId; switch (grantType) { case 'authorization_code': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url, credentialsId } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, forceFetch: true })); + ({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, forceFetch: true })); break; case 'client_credentials': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url, credentialsId } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, forceFetch: true })); + ({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, forceFetch: true })); break; case 'password': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url, credentialsId } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, forceFetch: true })); + ({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, forceFetch: true })); break; } - return { credentials, url, collectionUid, credentialsId }; + return { credentials, url, collectionUid, credentialsId, debugInfo }; } } catch (error) { return Promise.reject(error); @@ -843,11 +844,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection try { if (request.oauth2) { let requestCopy = _.cloneDeep(request); - const { uid: collectionUid, runtimeVariables, environments = [], activeEnvironmentUid } = collection; + const { uid: collectionUid, pathname: collectionPath, runtimeVariables, environments = [], activeEnvironmentUid } = collection; const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid); const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + const {newRequest} = await configureRequestWithCertsAndProxy({ + collectionUid, + request: requestCopy, + envVars, + runtimeVariables, + processEnvVars, + collectionPath + }); + requestCopy = newRequest let { credentials, url, credentialsId } = await refreshOauth2Token(requestCopy, collectionUid); return { credentials, url, collectionUid, credentialsId }; } diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js index 8e4abf4bb..090862c28 100644 --- a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js +++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js @@ -8,12 +8,15 @@ const matchesCallbackUrl = (url, callbackUrl) => { const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => { return new Promise(async (resolve, reject) => { let finalUrl = null; + let debugInfo = { + data: [] + }; + let currentMainRequest = null; let allOpenWindows = BrowserWindow.getAllWindows(); - // main window id is '1' - // get all other windows - let windowsExcludingMain = allOpenWindows.filter((w) => w.id != 1); + // Close all windows except the main window (assumed to have id 1) + let windowsExcludingMain = allOpenWindows.filter((w) => w.id !== 1); windowsExcludingMain.forEach((w) => { w.close(); }); @@ -27,26 +30,140 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => { }); window.on('ready-to-show', window.show.bind(window)); - // We want browser window to comply with "SSL/TLS Certificate Verification" toggle in Preferences + // Ensure the browser window complies with "SSL/TLS Certificate Verification" preference window.webContents.on('certificate-error', (event, url, error, certificate, callback) => { event.preventDefault(); callback(!preferencesUtil.shouldVerifyTls()); }); - function onWindowRedirect(url) { - // check if the redirect is to the callback URL and if it contains an authorization code - if (matchesCallbackUrl(new URL(url), new URL(callbackUrl))) { - if (!new URL(url).searchParams.has('code')) { - reject(new Error('Invalid Callback URL: Does not contain an authorization code')); + const { session: webSession } = window.webContents; + + // Map to store request data using requestId as the key + const requestMap = {}; + + // Intercept request events and gather data + webSession.webRequest.onBeforeRequest((details, callback) => { + const { id: requestId, url, method, resourceType, frameId } = details; + + const request = { + requestId, + url, + method, + resourceType, + frameId, + timestamp: Date.now(), + requestHeaders: {}, + responseHeaders: {}, + statusCode: null, + error: null, + fromCache: null, + completed: false, + }; + + requestMap[requestId] = request; + + if (resourceType === 'mainFrame') { + // This is a main frame request + currentMainRequest = { + requestId, + url, + method, + timestamp: request.timestamp, + requestHeaders: {}, + responseHeaders: {}, + statusCode: null, + error: null, + fromCache: null, + completed: false, + requests: [], // To hold sub-resource requests + }; + // Add to mainRequests + debugInfo.data.push(currentMainRequest); + } else if (currentMainRequest) { + // Associate sub-resource request with current main request + currentMainRequest.requests.push(request); + } + + callback({ cancel: false }); + }); + + webSession.webRequest.onBeforeSendHeaders((details, callback) => { + const { id: requestId, requestHeaders } = details; + if (requestMap[requestId]) { + requestMap[requestId].requestHeaders = requestHeaders; + } + + if (requestMap[requestId]?.resourceType === 'mainFrame') { + if (currentMainRequest?.requestId === requestId) { + currentMainRequest.requestHeaders = requestHeaders; } + } + + callback({ cancel: false, requestHeaders }); + }); + + webSession.webRequest.onHeadersReceived((details, callback) => { + const { id: requestId, statusCode, responseHeaders } = details; + if (requestMap[requestId]) { + requestMap[requestId].statusCode = statusCode; + requestMap[requestId].responseHeaders = responseHeaders; + } + + if (requestMap[requestId]?.resourceType === 'mainFrame') { + if (currentMainRequest?.requestId === requestId) { + currentMainRequest.statusCode = statusCode; + currentMainRequest.responseHeaders = responseHeaders; + } + } + + callback({ cancel: false, responseHeaders }); + }); + + webSession.webRequest.onCompleted((details) => { + const { id: requestId, fromCache } = details; + if (requestMap[requestId]) { + requestMap[requestId].completed = true; + requestMap[requestId].fromCache = fromCache; + } + + // If this is a mainFrame request, update currentMainRequest + if (requestMap[requestId]?.resourceType === 'mainFrame') { + if (currentMainRequest?.requestId === requestId) { + currentMainRequest.completed = true; + currentMainRequest.fromCache = fromCache; + } + } + }); + + webSession.webRequest.onErrorOccurred((details) => { + const { id: requestId, error } = details; + if (requestMap[requestId]) { + requestMap[requestId].error = error; + } + + // If this is a mainFrame request, update currentMainRequest + if (requestMap[requestId]?.resourceType === 'mainFrame') { + if (currentMainRequest?.requestId === requestId) { + currentMainRequest.error = error; + } + } + }); + + function onWindowRedirect(url) { + // Handle redirects as needed + + // Check if redirect is to the callback URL and contains an authorization code + if (matchesCallbackUrl(new URL(url), new URL(callbackUrl))) { finalUrl = url; window.close(); } - if (url.match(/(error=).*/) || url.match(/(error_description=).*/) || url.match(/(error_uri=).*/)) { - const _url = new URL(url); - const error = _url.searchParams.get('error'); - const errorDescription = _url.searchParams.get('error_description'); - const errorUri = _url.searchParams.get('error_uri'); + + // Handle OAuth error responses + const urlObj = new URL(url); + if (urlObj.searchParams.has('error')) { + const error = urlObj.searchParams.get('error'); + const errorDescription = urlObj.searchParams.get('error_description'); + const errorUri = urlObj.searchParams.get('error_uri'); let errorData = { message: 'Authorization Failed!', error, @@ -58,13 +175,37 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => { } } + // Update currentMainRequest when navigation occurs + window.webContents.on('did-start-navigation', (event, url, isInPlace, isMainFrame) => { + if (isMainFrame) { + // Reset currentMainRequest since a new navigation is starting + currentMainRequest = null; + } + }); + + window.webContents.on('did-navigate', (event, url) => { + onWindowRedirect(url); + }); + + window.webContents.on('will-redirect', (event, url) => { + onWindowRedirect(url); + }); + window.on('close', () => { + // Clean up listeners to prevent memory leaks + window.webContents.removeAllListeners(); + webSession.webRequest.onBeforeRequest(null); + webSession.webRequest.onBeforeSendHeaders(null); + webSession.webRequest.onHeadersReceived(null); + webSession.webRequest.onCompleted(null); + webSession.webRequest.onErrorOccurred(null); + if (finalUrl) { try { const callbackUrlWithCode = new URL(finalUrl); const authorizationCode = callbackUrlWithCode.searchParams.get('code'); - return resolve({ authorizationCode }); + return resolve({ authorizationCode, debugInfo }); } catch (error) { return reject(error); } @@ -73,20 +214,10 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => { } }); - // wait for the window to navigate to the callback url - const didNavigateListener = (_, url) => { - onWindowRedirect(url); - }; - window.webContents.on('did-navigate', didNavigateListener); - const willRedirectListener = (_, authorizeUrl) => { - onWindowRedirect(authorizeUrl); - }; - window.webContents.on('will-redirect', willRedirectListener); - try { await window.loadURL(authorizeUrl); } catch (error) { - // If browser redirects before load finished, loadURL throws an error with code ERR_ABORTED. This should be ignored. + // Ignore ERR_ABORTED errors that occur during redirects if (error.code === 'ERR_ABORTED') { console.debug('Ignoring ERR_ABORTED during authorizeUserInWindow'); return; @@ -97,4 +228,4 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => { }); }; -module.exports = { authorizeUserInWindow, matchesCallbackUrl }; +module.exports = { authorizeUserInWindow, matchesCallbackUrl }; \ No newline at end of file diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 640d1410f..f1f8dca51 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -277,7 +277,9 @@ const registerNetworkIpc = (mainWindow) => { credentials: request?.oauth2Credentials?.credentials, url: request?.oauth2Credentials?.url, collectionUid, - credentialsId: request?.oauth2Credentials?.credentialsId + credentialsId: request?.oauth2Credentials?.credentialsId, + ...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }), + debugInfo: request?.oauth2Credentials?.debugInfo, }); } @@ -407,7 +409,8 @@ const registerNetworkIpc = (mainWindow) => { data: response.data, dataBuffer: dataBuffer.toString('base64'), size: Buffer.byteLength(dataBuffer), - duration: responseTime ?? 0 + duration: responseTime ?? 0, + timeline: response.timeline }; } catch (error) { deleteCancelToken(cancelTokenUid); diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index 2f7e69665..d7ab410e9 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -173,7 +173,8 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || ''; request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || ''; request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || ''; - request.oauth2.reuseToken = _interpolate(request.oauth2.reuseToken) || false; + request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken); + request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken); break; case 'authorization_code': request.oauth2.callbackUrl = _interpolate(request.oauth2.callbackUrl) || ''; @@ -190,7 +191,8 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || ''; request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || ''; request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || ''; - request.oauth2.reuseToken = _interpolate(request.oauth2.reuseToken) || false; + request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken); + request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken); break; case 'client_credentials': request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || ''; @@ -203,7 +205,8 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || ''; request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || ''; request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || ''; - request.oauth2.reuseToken = _interpolate(request.oauth2.reuseToken) || false; + request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken); + request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken); break; default: break; diff --git a/packages/bruno-electron/src/utils/axios-instance.js b/packages/bruno-electron/src/utils/axios-instance.js index 4f7f9f8f6..dce06fffa 100644 --- a/packages/bruno-electron/src/utils/axios-instance.js +++ b/packages/bruno-electron/src/utils/axios-instance.js @@ -3,11 +3,31 @@ const Socket = require('net').Socket; const axios = require('axios'); const connectionCache = new Map(); // Cache to store checkConnection() results const electronApp = require("electron"); +const { setupProxyAgents } = require('./proxy-util'); +const { addCookieToJar, getCookieStringForUrl } = require('./cookies'); +const { preferencesUtil } = require('../store/preferences'); const LOCAL_IPV6 = '::1'; const LOCAL_IPV4 = '127.0.0.1'; const LOCALHOST = 'localhost'; const version = electronApp?.app?.getVersion()?.substring(1) ?? ""; +const redirectResponseCodes = [301, 302, 303, 307, 308]; + +const saveCookies = (url, headers) => { + if (preferencesUtil.shouldStoreCookies()) { + let setCookieHeaders = []; + if (headers['set-cookie']) { + setCookieHeaders = Array.isArray(headers['set-cookie']) + ? headers['set-cookie'] + : [headers['set-cookie']]; + for (let setCookieHeader of setCookieHeaders) { + if (typeof setCookieHeader === 'string' && setCookieHeader.length) { + addCookieToJar(setCookieHeader, url); + } + } + } + } +} const getTld = (hostname) => { if (!hostname) { @@ -49,13 +69,16 @@ const checkConnection = (host, port) => * @see https://github.com/axios/axios/issues/695 * @returns {axios.AxiosInstance} */ -function makeAxiosInstance() { +function makeAxiosInstance({ + proxyMode = 'off', + proxyConfig = {}, + requestMaxRedirects = 0, + httpsAgentRequestFields = {}, + interpolationOptions = {} +} = {}) { /** @type {axios.AxiosInstance} */ const instance = axios.create({ transformRequest: function transformRequest(data, headers) { - // doesn't apply the default transformRequest if the data is a string, so that axios doesn't add quotes see : - // https://github.com/usebruno/bruno/issues/2043 - // https://github.com/axios/axios/issues/4034 const contentType = headers?.['Content-Type'] || headers?.['content-type'] || ''; const hasJSONContentType = contentType.includes('json'); if (typeof data === 'string' && hasJSONContentType) { @@ -75,6 +98,51 @@ function makeAxiosInstance() { instance.interceptors.request.use(async (config) => { const url = URL.parse(config.url); + + config.metadata = config.metadata || {}; + config.metadata.startTime = new Date().getTime(); + config.metadata.timeline = config.metadata.timeline || []; + + // Add initial request details to the timeline + config.metadata.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Preparing request to ${config.url}`, + }); + config.metadata.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Current time is ${new Date().toISOString()}`, + }); + + // Add request method and headers + config.metadata.timeline.push({ + timestamp: new Date(), + type: 'request', + message: `${config.method.toUpperCase()} ${config.url}`, + }); + Object.entries(config.headers).forEach(([key, value]) => { + config.metadata.timeline.push({ + timestamp: new Date(), + type: 'requestHeader', + message: `${key}: ${value}`, + }); + }); + + // Add request data if available + if (config.data) { + let requestData; + try { + requestData = typeof config.data === 'string' ? config.data : JSON.stringify(config.data, null, 2); + } catch (err) { + requestData = config.data.toString(); + } + config.metadata.timeline.push({ + timestamp: new Date(), + type: 'requestData', + message: requestData, + }); + } // Resolve all *.localhost to localhost and check if it should use IPv6 or IPv4 // RFC: 6761 section 6.3 (https://tools.ietf.org/html/rfc6761#section-6.3) @@ -91,14 +159,73 @@ function makeAxiosInstance() { } config.headers['request-start-time'] = Date.now(); + + const agentOptions = { + ...httpsAgentRequestFields, + keepAlive: true, + }; + + // Now call setupProxyAgents and pass the timeline + setupProxyAgents({ + requestConfig: config, + proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings + proxyConfig: proxyConfig, + httpsAgentRequestFields: agentOptions, + interpolationOptions: interpolationOptions, // Provide your interpolation options + timeline: config.metadata.timeline, + }); return config; }); + let redirectCount = 0 + instance.interceptors.response.use( (response) => { const end = Date.now(); const start = response.config.headers['request-start-time']; response.headers['request-duration'] = end - start; + redirectCount = 0; + + const config = response.config; + const metadata = config.metadata; + const duration = end - metadata.startTime; + + const httpVersion = response.request?.res?.httpVersion || '1.1'; + metadata.timeline.push({ + timestamp: new Date(), + type: 'response', + message: `HTTP/${httpVersion} ${response.status} ${response.statusText}`, + }); + + if (httpVersion.startsWith('2')) { + metadata.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Using HTTP/2, server supports multiplexing`, + }); + } + + metadata.timeline.push({ + timestamp: new Date(), + type: 'response', + message: `HTTP/${response.httpVersion || '1.1'} ${response.status} ${response.statusText}`, + }); + Object.entries(response.headers).forEach(([key, value]) => { + metadata.timeline.push({ + timestamp: new Date(), + type: 'responseHeader', + message: `${key}: ${value}`, + }); + }); + metadata.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Request completed in ${duration} ms`, + }); + + // Attach the timeline to the response + response.timeline = metadata.timeline; + return response; }, (error) => { @@ -106,6 +233,86 @@ function makeAxiosInstance() { const end = Date.now(); const start = error.config.headers['request-start-time']; error.response.headers['request-duration'] = end - start; + const config = error.config; + const metadata = config.metadata; + const duration = end - metadata.startTime; + + if (error.response && redirectResponseCodes.includes(error.response.status)) { + + + metadata.timeline.push({ + timestamp: new Date(), + type: 'response', + message: `HTTP/${error.response.httpVersion || '1.1'} ${error.response.status} ${error.response.statusText}`, + }); + Object.entries(error.response.headers).forEach(([key, value]) => { + metadata.timeline.push({ + timestamp: new Date(), + type: 'responseHeader', + message: `${key}: ${value}`, + }); + }); + metadata.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Request completed in ${duration} ms`, + }); + + // Attach the timeline to the response + error.response.timeline = metadata.timeline; + + if (redirectCount >= requestMaxRedirects) { + const dataBuffer = Buffer.from(error.response.data); + + return { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: error.response.data, + dataBuffer: dataBuffer.toString('base64'), + size: Buffer.byteLength(dataBuffer), + duration: error.response.headers.get('request-duration') ?? 0 + }; + } + + // Increase redirect count + redirectCount++; + + const redirectUrl = error.response.headers.location; + + if (preferencesUtil.shouldStoreCookies()) { + saveCookies(redirectUrl, error.response.headers); + } + + // Create a new request config for the redirect + const requestConfig = { + ...error.config, + url: redirectUrl, + headers: { + ...error.config.headers, + }, + }; + + if (preferencesUtil.shouldSendCookies()) { + const cookieString = getCookieStringForUrl(error.response.headers.location); + if (cookieString && typeof cookieString === 'string' && cookieString.length) { + requestConfig.headers['cookie'] = cookieString; + } + } + + + setupProxyAgents({ + requestConfig, + proxyMode, + proxyConfig, + httpsAgentRequestFields, + interpolationOptions, + timeline: metadata.timeline + }); + + // Make the redirected request + return instance(requestConfig); + } } return Promise.reject(error); } diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 29a50629c..6cedc7e91 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -293,18 +293,65 @@ const getEnvVars = (environment = {}) => { const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => { let credentialsVariables = {}; oauth2Credentials.forEach(({ credentialsId, credentials }) => { - Object.entries(credentials).forEach(([key, value]) => { - credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value; - }); + if (credentials) { + Object.entries(credentials).forEach(([key, value]) => { + credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value; + }); + } }); return credentialsVariables; -} +}; +const mergeAuth = (collection, request, requestTreePath) => { + // Start with collection level auth (always consider collection auth as base) + let collectionAuth = get(collection, 'root.request.auth', { mode: 'none' }); + let effectiveAuth = collectionAuth; + let lastFolderWithAuth = null; + + // Traverse through the path to find the closest auth configuration + for (let i of requestTreePath) { + if (i.type === 'folder') { + const folderAuth = get(i, 'root.request.auth'); + // Only consider folders that have a valid auth mode + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveAuth = folderAuth; + lastFolderWithAuth = i; + } + } + } + + // If request is set to inherit, use the effective auth from collection/folders + if (request.auth.mode === 'inherit') { + request.auth = effectiveAuth; + + // For OAuth2, we need to handle credentials properly + if (effectiveAuth.mode === 'oauth2') { + if (lastFolderWithAuth) { + // If auth is from folder, add folderUid and clear itemUid + request.oauth2Credentials = { + ...request.oauth2Credentials, + folderUid: lastFolderWithAuth.uid, + itemUid: null, + mode: request.auth.mode + }; + } else { + // If auth is from collection, ensure no folderUid and no itemUid + request.oauth2Credentials = { + ...request.oauth2Credentials, + folderUid: null, + itemUid: null, + mode: request.auth.mode + }; + } + } + } +}; module.exports = { mergeHeaders, mergeVars, mergeScripts, + mergeAuth, getTreePathFromCollectionToItem, slash, findItemByPathname, diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index 83ced9e49..d5479efaf 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -7,21 +7,30 @@ const { safeParseJSON, safeStringifyJSON } = require('./common'); const oauth2Store = new Oauth2Store(); -// temp: this should be removed when more complex scenarios for fetching tokens are handled (refershing, automatic fetch, ...) -const ALWAYS_REUSE_ACCESS_TOKEN____UNLESS_FETCHED_MANUALLY = true; - const persistOauth2Credentials = ({ collectionUid, url, credentials, credentialsId }) => { - oauth2Store.updateCredentialsForCollection({ collectionUid, url, credentials, credentialsId }); -} + const enhancedCredentials = { + ...credentials, + created_at: Date.now(), + }; + oauth2Store.updateCredentialsForCollection({ collectionUid, url, credentials: enhancedCredentials, credentialsId }); +}; const clearOauth2Credentials = ({ collectionUid, url, credentialsId }) => { oauth2Store.clearCredentialsForCollection({ collectionUid, url, credentialsId }); -} +}; const getStoredOauth2Credentials = ({ collectionUid, url, credentialsId }) => { const credentials = oauth2Store.getCredentialsForCollection({ collectionUid, url, credentialsId }); return credentials; -} +}; + +const isTokenExpired = (credentials) => { + if (!credentials || !credentials.expires_in || !credentials.created_at) { + return true; // Assume expired if missing data + } + const expiryTime = credentials.created_at + credentials.expires_in * 1000; + return Date.now() > expiryTime; +}; // AUTHORIZATION CODE @@ -31,19 +40,79 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo let requestCopy = cloneDeep(request); const oAuth = get(requestCopy, 'oauth2', {}); - const { clientId, clientSecret, callbackUrl, scope, pkce, credentialsPlacement, authorizationUrl, credentialsId, reuseToken } = oAuth; + const { + clientId, + clientSecret, + callbackUrl, + scope, + pkce, + credentialsPlacement, + authorizationUrl, + credentialsId, + autoRefreshToken, + autoFetchToken, + } = oAuth; const url = requestCopy?.oauth2?.accessTokenUrl; + if (!forceFetch) { + const storedCredentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }); - if ((reuseToken || ALWAYS_REUSE_ACCESS_TOKEN____UNLESS_FETCHED_MANUALLY) && !forceFetch) { - const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }) || {}; - return { collectionUid, url, credentials, credentialsId }; + if (storedCredentials) { + // Token exists + if (!isTokenExpired(storedCredentials)) { + // Token is valid, use it + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } else { + // Token is expired + if (autoRefreshToken && storedCredentials.refresh_token) { + // Try to refresh token + try { + const refreshedCredentialsData = await refreshOauth2Token(requestCopy, collectionUid); + return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId }; + } catch (error) { + // Refresh failed + clearOauth2Credentials({ collectionUid, url, credentialsId }); + if (autoFetchToken) { + // Proceed to fetch new token + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } + } else if (autoRefreshToken && !storedCredentials.refresh_token) { + // Cannot refresh; try autoFetchToken + if (autoFetchToken) { + // Proceed to fetch new token + clearOauth2Credentials({ collectionUid, url, credentialsId }); + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } else if (!autoRefreshToken && autoFetchToken) { + // Proceed to fetch new token + clearOauth2Credentials({ collectionUid, url, credentialsId }); + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } + } else { + // No stored credentials + if (autoFetchToken && !storedCredentials) { + // Proceed to fetch new token + } else { + // Proceed without token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } } - const { authorizationCode } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge, collectionUid); + + // Fetch new token process + const { authorizationCode, debugInfo } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge, collectionUid); requestCopy.method = 'POST'; requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded'; requestCopy.headers['Accept'] = 'application/json'; - if (credentialsPlacement == "basic_auth_header") { + if (credentialsPlacement === "basic_auth_header") { requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; } const data = { @@ -51,22 +120,95 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo code: authorizationCode, redirect_uri: callbackUrl, client_id: clientId, - client_secret: clientSecret }; + if (clientSecret && credentialsPlacement !== "basic_auth_header") { + data.client_secret = clientSecret; + } if (pkce) { data['code_verifier'] = codeVerifier; } + if (scope) { + data.scope = scope; + } requestCopy.data = data; requestCopy.url = url; + + // Initialize variables to hold request and response data for debugging + let axiosRequestInfo = null; + let axiosResponseInfo = null; + try { const axiosInstance = makeAxiosInstance(); + // Interceptor to capture request data + axiosInstance.interceptors.request.use((config) => { + axiosRequestInfo = { + method: config.method.toUpperCase(), + url: config.url, + headers: config.headers, + data: config.data, + timestamp: Date.now(), + }; + return config; + }); + + // Interceptor to capture response data + axiosInstance.interceptors.response.use((response) => { + axiosResponseInfo = { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data, + timestamp: Date.now(), + }; + return response; + }, (error) => { + if (error.response) { + axiosResponseInfo = { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: error.response.data, + timestamp: Date.now(), + }; + } + return Promise.reject(error); + }); + const response = await axiosInstance(requestCopy); - const responseData = Buffer.isBuffer(response.data) ? response.data?.toString() : response.data; - const parsedResponseData = safeParseJSON(responseData); + const parsedResponseData = safeParseJSON( + Buffer.isBuffer(response.data) ? response.data.toString() : response.data + ); + // Ensure debugInfo.data is initialized + if (!debugInfo) { + debugInfo = { data: [] }; + } else if (!debugInfo.data) { + debugInfo.data = []; + } + + // Add the axios request and response info as a main request in debugInfo + const axiosMainRequest = { + requestId: Date.now().toString(), // Generate a unique requestId + url: axiosRequestInfo.url, + method: axiosRequestInfo.method, + timestamp: axiosRequestInfo.timestamp, + requestHeaders: axiosRequestInfo.headers || {}, + requestBody: axiosRequestInfo.data, + responseHeaders: axiosResponseInfo.headers || {}, + responseBody: parsedResponseData, + statusCode: axiosResponseInfo.status || null, + statusMessage: axiosResponseInfo.statusText || null, + error: null, + fromCache: false, + completed: true, + requests: [], // No sub-requests in this context + }; + + debugInfo.data.push(axiosMainRequest); + persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId }); - return { collectionUid, url, credentials: parsedResponseData, credentialsId }; - } - catch (error) { + + return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo }; + } catch (error) { return Promise.reject(safeStringifyJSON(error?.response?.data)); } }; @@ -94,12 +236,12 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { } try { const authorizeUrl = authorizationUrlWithQueryParams.toString(); - const { authorizationCode } = await authorizeUserInWindow({ + const { authorizationCode, debugInfo } = await authorizeUserInWindow({ authorizeUrl, callbackUrl, session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl }) }); - resolve({ authorizationCode }); + resolve({ authorizationCode, debugInfo }); } catch (err) { reject(err); } @@ -111,42 +253,158 @@ const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => { const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false }) => { let requestCopy = cloneDeep(request); const oAuth = get(requestCopy, 'oauth2', {}); - const { clientId, clientSecret, scope, credentialsPlacement, credentialsId, reuseToken } = oAuth; + const { + clientId, + clientSecret, + scope, + credentialsPlacement, + credentialsId, + autoRefreshToken, + autoFetchToken, + } = oAuth; const url = requestCopy?.oauth2?.accessTokenUrl; - if ((reuseToken || ALWAYS_REUSE_ACCESS_TOKEN____UNLESS_FETCHED_MANUALLY) && !forceFetch) { - const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }) || {}; - return { collectionUid, url, credentials, credentialsId }; + if (!forceFetch) { + const storedCredentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }); + + if (storedCredentials) { + // Token exists + if (!isTokenExpired(storedCredentials)) { + // Token is valid, use it + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } else { + // Token is expired + if (autoRefreshToken && storedCredentials.refresh_token) { + // Try to refresh token + try { + const refreshedCredentialsData = await refreshOauth2Token(requestCopy, collectionUid); + return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId }; + } catch (error) { + clearOauth2Credentials({ collectionUid, url, credentialsId }); + if (autoFetchToken) { + // Proceed to fetch new token + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } + } else if (autoRefreshToken && !storedCredentials.refresh_token) { + if (autoFetchToken) { + // Proceed to fetch new token + clearOauth2Credentials({ collectionUid, url, credentialsId }); + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } else if (!autoRefreshToken && autoFetchToken) { + // Proceed to fetch new token + clearOauth2Credentials({ collectionUid, url, credentialsId }); + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } + } else { + // No stored credentials + if (autoFetchToken && !storedCredentials) { + // Proceed to fetch new token + } else { + // Proceed without token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } } + // Fetch new token process requestCopy.method = 'POST'; requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded'; requestCopy.headers['Accept'] = 'application/json'; - if (credentialsPlacement == "basic_auth_header") { + if (credentialsPlacement === "basic_auth_header") { requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; } const data = { grant_type: 'client_credentials', client_id: clientId, - client_secret: clientSecret }; + if (clientSecret && credentialsPlacement !== "basic_auth_header") { + data.client_secret = clientSecret; + } if (scope) { data.scope = scope; } requestCopy.data = data; requestCopy.url = url; - const axiosInstance = makeAxiosInstance(); + // Initialize variables to hold request and response data for debugging + let axiosRequestInfo = null; + let axiosResponseInfo = null; + let debugInfo = { data: [] }; try { + const axiosInstance = makeAxiosInstance(); + // Interceptor to capture request data + axiosInstance.interceptors.request.use((config) => { + axiosRequestInfo = { + method: config.method.toUpperCase(), + url: config.url, + headers: config.headers, + data: config.data, + timestamp: Date.now(), + }; + return config; + }); + + // Interceptor to capture response data + axiosInstance.interceptors.response.use((response) => { + axiosResponseInfo = { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data, + timestamp: Date.now(), + }; + return response; + }, (error) => { + if (error.response) { + axiosResponseInfo = { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: error.response.data, + timestamp: Date.now(), + }; + } + return Promise.reject(error); + }); + const response = await axiosInstance(requestCopy); - const responseData = Buffer.isBuffer(response.data) ? response.data?.toString() : response.data; + const responseData = Buffer.isBuffer(response.data) ? response.data.toString() : response.data; const parsedResponseData = safeParseJSON(responseData); + + // Add the axios request and response info as a main request in debugInfo + const axiosMainRequest = { + requestId: Date.now().toString(), + url: axiosRequestInfo.url, + method: axiosRequestInfo.method, + timestamp: axiosRequestInfo.timestamp, + requestHeaders: axiosRequestInfo.headers || {}, + requestBody: axiosRequestInfo.data, + responseHeaders: axiosResponseInfo.headers || {}, + responseBody: parsedResponseData, + statusCode: axiosResponseInfo.status || null, + statusMessage: axiosResponseInfo.statusText || null, + error: null, + fromCache: false, + completed: true, + requests: [], // No sub-requests in this context + }; + + debugInfo.data.push(axiosMainRequest); + persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId }); - return { collectionUid, url, credentials: parsedResponseData, credentialsId }; - } - catch (error) { + return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo }; + } catch (error) { return Promise.reject(safeStringifyJSON(error?.response?.data)); } }; @@ -156,18 +414,76 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false }) => { let requestCopy = cloneDeep(request); const oAuth = get(requestCopy, 'oauth2', {}); - const { username, password, clientId, clientSecret, scope, credentialsPlacement, credentialsId, reuseToken } = oAuth; + const { + username, + password, + clientId, + clientSecret, + scope, + credentialsPlacement, + credentialsId, + autoRefreshToken, + autoFetchToken, + } = oAuth; const url = requestCopy?.oauth2?.accessTokenUrl; - if ((reuseToken || ALWAYS_REUSE_ACCESS_TOKEN____UNLESS_FETCHED_MANUALLY) && !forceFetch) { - const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }) || {}; - return { collectionUid, url, credentials, credentialsId }; + if (!forceFetch) { + const storedCredentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }); + + if (storedCredentials) { + // Token exists + if (!isTokenExpired(storedCredentials)) { + // Token is valid, use it + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } else { + // Token is expired + if (autoRefreshToken && storedCredentials.refresh_token) { + // Try to refresh token + try { + const refreshedCredentialsData = await refreshOauth2Token(requestCopy, collectionUid); + return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId }; + } catch (error) { + clearOauth2Credentials({ collectionUid, url, credentialsId }); + if (autoFetchToken) { + // Proceed to fetch new token + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } + } else if (autoRefreshToken && !storedCredentials.refresh_token) { + // Cannot refresh; try autoFetchToken + if (autoFetchToken) { + // Proceed to fetch new token + clearOauth2Credentials({ collectionUid, url, credentialsId }); + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } else if (!autoRefreshToken && autoFetchToken) { + // Proceed to fetch new token + clearOauth2Credentials({ collectionUid, url, credentialsId }); + } else { + // Proceed with expired token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } + } else { + // No stored credentials + if (autoFetchToken && !storedCredentials) { + // Proceed to fetch new token + } else { + // Proceed without token + return { collectionUid, url, credentials: storedCredentials, credentialsId }; + } + } } + // Fetch new token process requestCopy.method = 'POST'; requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded'; requestCopy.headers['Accept'] = 'application/json'; - if (credentialsPlacement == "basic_auth_header") { + if (credentialsPlacement === "basic_auth_header") { requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; } const data = { @@ -175,46 +491,108 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, username, password, client_id: clientId, - client_secret: clientSecret }; + if (clientSecret && credentialsPlacement !== "basic_auth_header") { + data.client_secret = clientSecret; + } if (scope) { data.scope = scope; } requestCopy.data = data; requestCopy.url = url; + // Initialize variables to hold request and response data for debugging + let axiosRequestInfo = null; + let axiosResponseInfo = null; + let debugInfo = { data: [] }; + try { const axiosInstance = makeAxiosInstance(); + // Interceptor to capture request data + axiosInstance.interceptors.request.use((config) => { + axiosRequestInfo = { + method: config.method.toUpperCase(), + url: config.url, + headers: config.headers, + data: config.data, + timestamp: Date.now(), + }; + return config; + }); + + // Interceptor to capture response data + axiosInstance.interceptors.response.use((response) => { + axiosResponseInfo = { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data, + timestamp: Date.now(), + }; + return response; + }, (error) => { + if (error.response) { + axiosResponseInfo = { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: error.response.data, + timestamp: Date.now(), + }; + } + return Promise.reject(error); + }); + const response = await axiosInstance(requestCopy); - const responseData = Buffer.isBuffer(response.data) ? response.data?.toString() : response.data; + const responseData = Buffer.isBuffer(response.data) ? response.data.toString() : response.data; const parsedResponseData = safeParseJSON(responseData); + + // Add the axios request and response info as a main request in debugInfo + const axiosMainRequest = { + requestId: Date.now().toString(), + url: axiosRequestInfo.url, + method: axiosRequestInfo.method, + timestamp: axiosRequestInfo.timestamp, + requestHeaders: axiosRequestInfo.headers || {}, + requestBody: axiosRequestInfo.data, + responseHeaders: axiosResponseInfo.headers || {}, + responseBody: parsedResponseData, + statusCode: axiosResponseInfo.status || null, + statusMessage: axiosResponseInfo.statusText || null, + error: null, + fromCache: false, + completed: true, + requests: [], // No sub-requests in this context + }; + + debugInfo.data.push(axiosMainRequest); + persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId }); - return { collectionUid, url, credentials: parsedResponseData, credentialsId }; - } - catch (error) { + return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo }; + } catch (error) { return Promise.reject(safeStringifyJSON(error?.response?.data)); } }; -const refreshOauth2Token = async (request, collectionUid) => { - let requestCopy = cloneDeep(request); +const refreshOauth2Token = async (requestCopy, collectionUid) => { const oAuth = get(requestCopy, 'oauth2', {}); const { clientId, clientSecret, credentialsId } = oAuth; - const url = requestCopy?.oauth2?.refreshUrl ? requestCopy?.oauth2?.refreshUrl : requestCopy?.oauth2?.accessTokenUrl; + const url = oAuth.refreshUrl ? oAuth.refreshUrl : oAuth.accessTokenUrl; const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId }); if (!credentials?.refresh_token) { clearOauth2Credentials({ collectionUid, url, credentialsId }); - let error = new Error('no refresh token found'); - return Promise.reject(error); - } - else { + // Proceed without token + return { collectionUid, url, credentials: null, credentialsId }; + } else { const data = { grant_type: 'refresh_token', client_id: clientId, - client_secret: clientSecret, - refresh_token: credentials?.refresh_token + refresh_token: credentials.refresh_token, }; + if (clientSecret) { + data.client_secret = clientSecret; + } requestCopy.method = 'POST'; requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded'; requestCopy.headers['Accept'] = 'application/json'; @@ -224,18 +602,17 @@ const refreshOauth2Token = async (request, collectionUid) => { const axiosInstance = makeAxiosInstance(); try { const response = await axiosInstance(requestCopy); - const responseData = Buffer.isBuffer(response.data) ? response.data?.toString() : response.data; + const responseData = Buffer.isBuffer(response.data) ? response.data.toString() : response.data; const parsedResponseData = safeParseJSON(responseData); persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId }); return { collectionUid, url, credentials: parsedResponseData, credentialsId }; - } - catch (error) { + } catch (error) { clearOauth2Credentials({ collectionUid, url, credentialsId }); - return Promise.reject(safeStringifyJSON(error?.response?.data)); + // Proceed without token + return { collectionUid, url, credentials: null, credentialsId }; } } -} - +}; // HELPER FUNCTIONS @@ -246,8 +623,11 @@ const generateCodeVerifier = () => { const generateCodeChallenge = (codeVerifier) => { const hash = crypto.createHash('sha256'); hash.update(codeVerifier); - const base64Hash = hash.digest('base64'); - return base64Hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const base64Hash = hash.digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return base64Hash; }; module.exports = { @@ -256,4 +636,4 @@ module.exports = { getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token -}; +}; \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js index ef64d37ad..1b67d2b51 100644 --- a/packages/bruno-electron/src/utils/proxy-util.js +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -1,6 +1,11 @@ const parseUrl = require('url').parse; -const { isEmpty } = require('lodash'); +const https = require('https'); const { HttpsProxyAgent } = require('https-proxy-agent'); +const { interpolateString } = require('../ipc/network/interpolate-string'); +const { SocksProxyAgent } = require('socks-proxy-agent'); +const { HttpProxyAgent } = require('http-proxy-agent'); +const { preferencesUtil } = require('../store/preferences'); +const { isEmpty, get, isUndefined, isNull } = require('lodash'); const DEFAULT_PORTS = { ftp: 21, @@ -79,7 +84,254 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } } +function createTimelineAgentClass(BaseAgentClass) { + return class extends BaseAgentClass { + constructor(options, timeline) { + super(options); + this.timeline = timeline; + this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1']; + this.caProvided = !!options.ca; + } + + createConnection(options, callback) { + const { host, port } = options; + + // Log SSL validation + this.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Enable SSL validation`, + }); + + // Log ALPN protocols offered + if (this.alpnProtocols && this.alpnProtocols.length > 0) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `ALPN: offers ${this.alpnProtocols.join(', ')}`, + }); + } + + // Log CAfile and CApath (if possible) + if (this.caProvided) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `CA certificates provided`, + }); + } else { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `Using system default CA certificates`, + }); + } + + // Log "Trying host:port..." + this.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Trying ${host}:${port}...`, + }); + + const socket = super.createConnection(options, callback); + + // Attach event listeners to the socket + socket.on('lookup', (err, address, family, host) => { + if (err) { + this.timeline.push({ + timestamp: new Date(), + type: 'error', + message: `DNS lookup error for ${host}: ${err.message}`, + }); + } else { + this.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `DNS lookup: ${host} -> ${address}`, + }); + } + }); + + socket.on('connect', () => { + const address = socket.remoteAddress || host; + const remotePort = socket.remotePort || port; + + this.timeline.push({ + timestamp: new Date(), + type: 'info', + message: `Connected to ${host} (${address}) port ${remotePort}`, + }); + }); + + socket.on('secureConnect', () => { + const protocol = socket.getProtocol() || 'SSL/TLS'; + const cipher = socket.getCipher(); + const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher'; + + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `SSL connection using ${protocol} / ${cipherSuite}`, + }); + + // ALPN protocol + const alpnProtocol = socket.alpnProtocol || 'None'; + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `ALPN: server accepted ${alpnProtocol}`, + }); + + // Server certificate + const cert = socket.getPeerCertificate(true); + if (cert) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `Server certificate:`, + }); + if (cert.subject) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`, + }); + } + if (cert.valid_from) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: ` start date: ${cert.valid_from}`, + }); + } + if (cert.valid_to) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: ` expire date: ${cert.valid_to}`, + }); + } + if (cert.subjectaltname) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: ` subjectAltName: ${cert.subjectaltname}`, + }); + } + if (cert.issuer) { + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`, + }); + } + + // SSL certificate verify ok + this.timeline.push({ + timestamp: new Date(), + type: 'tls', + message: `SSL certificate verify ok.`, + }); + } + }); + + socket.on('error', (err) => { + this.timeline.push({ + timestamp: new Date(), + type: 'error', + message: `Socket error: ${err.message}`, + }); + }); + + return socket; + } + }; +} + +function setupProxyAgents({ + requestConfig, + proxyMode = 'off', + proxyConfig, + httpsAgentRequestFields, + interpolationOptions, + timeline, +}) { + if (proxyMode === 'on') { + const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', '')); + if (shouldProxy) { + const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); + const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); + const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); + const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const socksEnabled = proxyProtocol.includes('socks'); + + let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; + let proxyUri; + if (proxyAuthEnabled) { + const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions); + const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions); + proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; + } else { + proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; + } + + if (socksEnabled) { + const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent); + requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline); + requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...httpsAgentRequestFields }, timeline); + } else { + const TimelineHttpsProxyAgent = createTimelineAgentClass(HttpsProxyAgent); + requestConfig.httpAgent = new HttpProxyAgent(proxyUri); // For http, no need for timeline + requestConfig.httpsAgent = new TimelineHttpsProxyAgent( + proxyUri, + { ...httpsAgentRequestFields }, + timeline + ); + } + } else { + // If proxy should not be used, set default HTTPS agent + const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); + requestConfig.httpsAgent = new TimelineHttpsAgent(httpsAgentRequestFields, timeline); + } + } else if (proxyMode === 'system') { + const { http_proxy, https_proxy, no_proxy } = preferencesUtil.getSystemProxyEnvVariables(); + const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || ''); + if (shouldUseSystemProxy) { + try { + if (http_proxy?.length) { + new URL(http_proxy); + requestConfig.httpAgent = new HttpProxyAgent(http_proxy); + } + } catch (error) { + throw new Error('Invalid system http_proxy'); + } + try { + if (https_proxy?.length) { + new URL(https_proxy); + const TimelineHttpsProxyAgent = createTimelineAgentClass(HttpsProxyAgent); + requestConfig.httpsAgent = new TimelineHttpsProxyAgent( + https_proxy, + { ...httpsAgentRequestFields }, + timeline + ); + } + } catch (error) { + throw new Error('Invalid system https_proxy'); + } + } else { + const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); + requestConfig.httpsAgent = new TimelineHttpsAgent(httpsAgentRequestFields, timeline); + } + } else { + const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); + requestConfig.httpsAgent = new TimelineHttpsAgent(httpsAgentRequestFields, timeline); + } +} + + module.exports = { shouldUseProxy, - PatchedHttpsProxyAgent + PatchedHttpsProxyAgent, + setupProxyAgents }; diff --git a/packages/bruno-electron/src/utils/request.js b/packages/bruno-electron/src/utils/request.js index 1fe5b7c70..e7ed7b3e7 100644 --- a/packages/bruno-electron/src/utils/request.js +++ b/packages/bruno-electron/src/utils/request.js @@ -10,9 +10,9 @@ const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const iconv = require('iconv-lite'); const { interpolate } = require('@usebruno/common'); -const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials } = require('./collection'); +const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials, mergeAuth } = require('./collection'); const { buildFormUrlEncodedPayload } = require('./form-data'); -const { shouldUseProxy, PatchedHttpsProxyAgent } = require('./proxy-util'); +const { setupProxyAgents } = require('./proxy-util'); const { makeAxiosInstance } = require('./axios-instance'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials } = require('./oauth2'); const { resolveAwsV4Credentials, addAwsV4Interceptor, addDigestInterceptor } = require('./auth'); @@ -103,7 +103,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'), - reuseToken: get(collectionAuth, 'oauth2.reuseToken') + autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'), + autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken') }; break; case 'authorization_code': @@ -123,7 +124,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'), - reuseToken: get(collectionAuth, 'oauth2.reuseToken') + autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'), + autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken') }; break; case 'client_credentials': @@ -139,7 +141,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'), tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'), tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'), - reuseToken: get(collectionAuth, 'oauth2.reuseToken') + autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'), + autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken') }; break; } @@ -198,7 +201,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'), - reuseToken: get(request, 'auth.oauth2.reuseToken') + autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'), + autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken') }; break; case 'authorization_code': @@ -218,7 +222,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'), - reuseToken: get(request, 'auth.oauth2.reuseToken') + autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'), + autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken') }; break; case 'client_credentials': @@ -234,7 +239,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'), tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'), tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'), - reuseToken: get(request, 'auth.oauth2.reuseToken') + autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'), + autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken') }; break; } @@ -292,6 +298,7 @@ const prepareRequest = (item, collection) => { mergeHeaders(collection, request, requestTreePath); mergeScripts(collection, request, requestTreePath, scriptFlow); mergeVars(collection, request, requestTreePath); + mergeAuth(collection, request, requestTreePath); request.globalEnvironmentVariables = collection?.globalEnvironmentVariables; request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials }); } @@ -392,6 +399,7 @@ const prepareRequest = (item, collection) => { axiosRequest.globalEnvironmentVariables = request.globalEnvironmentVariables; axiosRequest.oauth2CredentialVariables = request.oauth2CredentialVariables; axiosRequest.assertions = request.assertions; + axiosRequest.oauth2Credentials = request.oauth2Credentials; return axiosRequest; }; @@ -536,77 +544,15 @@ const configureRequestWithCertsAndProxy = async ({ proxyMode = get(proxyConfig, 'mode', 'off'); } - if (proxyMode === 'on') { - const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', '')); - if (shouldProxy) { - const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); - const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); - const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); - const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); - const socksEnabled = proxyProtocol.includes('socks'); - let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; - let proxyUri; - if (proxyAuthEnabled) { - const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions); - const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions); + setupProxyAgents({ + requestConfig: request, + proxyMode, + proxyConfig, + httpsAgentRequestFields, + interpolationOptions + }); - proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; - } else { - proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; - } - if (socksEnabled) { - request.httpsAgent = new SocksProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - request.httpAgent = new SocksProxyAgent(proxyUri); - } else { - request.httpsAgent = new PatchedHttpsProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - request.httpAgent = new HttpProxyAgent(proxyUri); - } - } else { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); - } - } else if (proxyMode === 'system') { - const { http_proxy, https_proxy, no_proxy } = preferencesUtil.getSystemProxyEnvVariables(); - const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || ''); - if (shouldUseSystemProxy) { - try { - if (http_proxy?.length) { - new URL(http_proxy); - request.httpAgent = new HttpProxyAgent(http_proxy); - } - } catch (error) { - throw new Error('Invalid system http_proxy'); - } - try { - if (https_proxy?.length) { - new URL(https_proxy); - request.httpsAgent = new PatchedHttpsProxyAgent( - https_proxy, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - } - } catch (error) { - throw new Error('Invalid system https_proxy'); - } - } else { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); - } - } else if (Object.keys(httpsAgentRequestFields).length > 0) { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); - } - - return request; + return {proxyMode, newRequest: request, proxyConfig, httpsAgentRequestFields, interpolationOptions}; } const configureRequest = async ( @@ -622,7 +568,7 @@ const configureRequest = async ( request.url = `http://${request.url}`; } - request = await configureRequestWithCertsAndProxy({ + const {proxyMode, newRequest, proxyConfig, httpsAgentRequestFields, interpolationOptions} = await configureRequestWithCertsAndProxy({ collectionUid, request, envVars, @@ -631,7 +577,17 @@ const configureRequest = async ( collectionPath }); - const axiosInstance = makeAxiosInstance(); + request = newRequest + let requestMaxRedirects = request.maxRedirects + request.maxRedirects = 0 + + let axiosInstance = makeAxiosInstance({ + proxyMode, + proxyConfig, + requestMaxRedirects, + httpsAgentRequestFields, + interpolationOptions + }); if (request.ntlmConfig) { axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) @@ -645,8 +601,8 @@ const configureRequest = async ( switch (grantType) { case 'authorization_code': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId }; + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid })); + request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; } @@ -661,8 +617,8 @@ const configureRequest = async ( break; case 'client_credentials': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId }; + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid })); + request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; } @@ -677,8 +633,8 @@ const configureRequest = async ( break; case 'password': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - ({ credentials, url: oauth2Url, credentialsId } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid })); - request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId }; + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid })); + request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; } diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 7f1f059f5..104fefc22 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -490,7 +490,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { const tokenPlacementKey = _.find(auth, { name: 'token_placement' }); const tokenHeaderPrefixKey = _.find(auth, { name: 'token_header_prefix' }); const tokenQueryKeyKey = _.find(auth, { name: 'token_query_key' }); - const reuseTokenKey = _.find(auth, { name: 'reuse_token' }); + const autoFetchTokenKey = _.find(auth, { name: 'auto_fetch_token' }); + const autoRefreshTokenKey = _.find(auth, { name: 'auto_refresh_token' }); return { auth: { oauth2: @@ -509,7 +510,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - reuseToken: reuseTokenKey?.value ? JSON.parse(reuseTokenKey?.value || false) : false + autoFetchToken: autoFetchTokenKey ? JSON.parse(autoFetchTokenKey?.value) : true, + autoRefreshToken: autoRefreshTokenKey ? JSON.parse(autoRefreshTokenKey?.value) : true } : grantTypeKey?.value && grantTypeKey?.value == 'authorization_code' ? { @@ -528,7 +530,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - reuseToken: reuseTokenKey?.value ? JSON.parse(reuseTokenKey?.value || false) : false + autoFetchToken: autoFetchTokenKey ? JSON.parse(autoFetchTokenKey?.value) : true, + autoRefreshToken: autoRefreshTokenKey ? JSON.parse(autoRefreshTokenKey?.value) : true } : grantTypeKey?.value && grantTypeKey?.value == 'client_credentials' ? { @@ -543,7 +546,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - reuseToken: reuseTokenKey?.value ? JSON.parse(reuseTokenKey?.value || false) : false + autoFetchToken: autoFetchTokenKey ? JSON.parse(autoFetchTokenKey?.value) : true, + autoRefreshToken: autoRefreshTokenKey ? JSON.parse(autoRefreshTokenKey?.value) : true } : {} } diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index b6ebdd5af..7376b71e0 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -285,7 +285,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { const tokenPlacementKey = _.find(auth, { name: 'token_placement' }); const tokenHeaderPrefixKey = _.find(auth, { name: 'token_header_prefix' }); const tokenQueryKeyKey = _.find(auth, { name: 'token_query_key' }); - const reuseTokenKey = _.find(auth, { name: 'reuseToken' }); + const autoFetchTokenKey = _.find(auth, { name: 'auto_fetch_token' }); + const autoRefreshTokenKey = _.find(auth, { name: 'auto_refresh_token' }); return { auth: { oauth2: @@ -304,7 +305,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - reuseToken: reuseTokenKey?.value ? JSON.parse(reuseTokenKey?.value || false) : false + autoFetchToken: autoFetchTokenKey ? JSON.parse(autoFetchTokenKey?.value) : true, + autoRefreshToken: autoRefreshTokenKey ? JSON.parse(autoRefreshTokenKey?.value) : true } : grantTypeKey?.value && grantTypeKey?.value == 'authorization_code' ? { @@ -323,7 +325,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - reuseToken: reuseTokenKey?.value ? JSON.parse(reuseTokenKey?.value || false) : false + autoFetchToken: autoFetchTokenKey ? JSON.parse(autoFetchTokenKey?.value) : true, + autoRefreshToken: autoRefreshTokenKey ? JSON.parse(autoRefreshTokenKey?.value) : true } : grantTypeKey?.value && grantTypeKey?.value == 'client_credentials' ? { @@ -338,7 +341,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', - reuseToken: reuseTokenKey?.value ? JSON.parse(reuseTokenKey?.value || false) : false + autoFetchToken: autoFetchTokenKey ? JSON.parse(autoFetchTokenKey?.value) : true, + autoRefreshToken: autoRefreshTokenKey ? JSON.parse(autoRefreshTokenKey?.value) : true } : {} } diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 9675e5e35..de4193473 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -196,7 +196,8 @@ ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ }${ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' } -${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)} +${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken).toString()}`)} +${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken).toString()}`)} } `; @@ -220,7 +221,8 @@ ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ }${ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' } -${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)} +${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken).toString()}`)} +${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken).toString()}`)} } `; @@ -240,7 +242,8 @@ ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ }${ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' } -${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)} +${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken).toString()}`)} +${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken).toString()}`)} } `; diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js index 0d8ac5eec..16775720a 100644 --- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js +++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js @@ -162,7 +162,8 @@ ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ }${ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' } -${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)} +${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken).toString()}`)} +${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken).toString()}`)} } `; @@ -186,7 +187,8 @@ ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ }${ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' } -${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)} +${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken).toString()}`)} +${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken).toString()}`)} } `; @@ -206,7 +208,8 @@ ${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${ }${ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : '' } -${indentString(`reuse_token: ${auth?.oauth2?.reuseToken || ''}`)} +${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken).toString()}`)} +${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken).toString()}`)} } `; diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index d5645a690..8b8ed47c5 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -227,20 +227,20 @@ const oauth2Schema = Yup.object({ then: Yup.string().nullable(), otherwise: Yup.string().nullable().strip() }), - reuseToken: Yup.boolean().when('grantType', { - is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val), - then: Yup.boolean().default(false), - otherwise: Yup.boolean() - }), refreshUrl: Yup.string().when('grantType', { is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val), then: Yup.string().nullable(), otherwise: Yup.string().nullable().strip() }), - autoRefresh: Yup.boolean().when('grantType', { + autoRefreshToken: Yup.boolean().when('grantType', { is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val), then: Yup.boolean().default(false), otherwise: Yup.boolean() + }), + autoFetchToken: Yup.boolean().when('grantType', { + is: (val) => ['authorization_code'].includes(val), + then: Yup.boolean().default(true), + otherwise: Yup.boolean() }) }) .noUnknown(true)