Oauth2 folder (#4105)

This commit is contained in:
naman-bruno
2025-03-06 11:03:34 +05:30
committed by GitHub
parent 4e88cbf318
commit 3169e6cdf4
37 changed files with 2703 additions and 411 deletions

View File

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

View File

@@ -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 <OAuth2PasswordCredentials save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
case 'authorization_code':
return <OAuth2AuthorizationCode save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
case 'client_credentials':
return <OAuth2ClientCredentials save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
default:
return <div>TBD</div>;
}
};
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 (
<>
<GrantTypeSelector
request={request}
updateAuth={updateFolderAuth}
collection={collection}
folder={folder}
/>
{grantTypeComponentMap(collection, folder)}
</>
);
}
case 'none': {
return null;
}
default:
return null;
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
Configures authentication for the entire folder. This applies to all requests using the{' '}
<span className="font-medium">Inherit</span> option in the <span className="font-medium">Auth</span> tab.
</div>
<div className="flex flex-grow justify-start items-center mb-4">
<AuthMode collection={collection} folder={folder} />
</div>
{getAuthView()}
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</StyledWrapper>
);
};
export default Auth;

View File

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

View File

@@ -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 (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateFolderAuthMode({
mode: value,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('oauth2');
}}
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default AuthMode;

View File

@@ -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 <Vars collection={collection} folder={folder} />;
}
case 'auth': {
return <Auth collection={collection} folder={folder} />;
}
case 'docs': {
return <Documentation collection={collection} folder={folder} />;
}
@@ -93,6 +101,10 @@ const FolderSettings = ({ collection, folder }) => {
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{hasAuth && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
</div>

View File

@@ -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 (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
@@ -54,7 +58,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
@@ -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
/>
</div>
</div>
:
:
<div className="flex items-center gap-4 w-full" key={`input-token-query-param-key`}>
<label className="block font-medium min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
@@ -340,24 +349,58 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
</div>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Auto-refresh token</label>
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefresh', false)}
onChange={(e) => handleChange('autoRefresh', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">Settings</span>
</div>
{/* Automatically Fetch Token */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoFetchToken)}
onChange={(e) => handleChange('autoFetchToken', e.target.checked)}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Automatically fetch token if not found</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when you try to access a resource and don't have one.
</span>
</div>
</div>
</div>
{/* Auto Refresh Token (With Refresh URL) */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoRefreshToken)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isAutoRefreshDisabled}
/>
<label className={`block min-w-[140px] ${isAutoRefreshDisabled ? 'text-gray-500' : ''}`}>Auto refresh token (with refresh URL)</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically refresh your token using the refresh URL when it expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
Get Access Token{fetchingToken ? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
{showRefreshButton && (
<button onClick={handleRefreshAccessToken} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Refresh Token{refreshingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
Refresh Token{refreshingToken ? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
)}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">

View File

@@ -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';
@@ -23,7 +23,23 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
const oAuth = get(request, 'auth.oauth2', {});
const { accessTokenUrl, clientId, clientSecret, scope, credentialsPlacement, credentialsId, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, reuseToken, refreshUrl, autoRefresh } = oAuth;
const {
accessTokenUrl,
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshUrl,
autoRefreshToken,
autoFetchToken
} = oAuth;
const refreshUrlAvailable = refreshUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshUrlAvailable;
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
@@ -97,9 +113,9 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
reuseToken,
refreshUrl,
autoRefresh,
autoRefreshToken,
autoFetchToken,
[key]: value
}
})
@@ -278,12 +294,58 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefresh', false)}
onChange={(e) => handleChange('autoRefresh', e.target.checked)}
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">Settings</span>
</div>
{/* Automatically Fetch Token */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoFetchToken)}
onChange={(e) => handleChange('autoFetchToken', e.target.checked)}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Automatically fetch token if not found</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when you try to access a resource and don't have one.
</span>
</div>
</div>
</div>
{/* Auto Refresh Token (With Refresh URL) */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoRefreshToken)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isAutoRefreshDisabled}
/>
<label className={`block min-w-[140px] ${isAutoRefreshDisabled ? 'text-gray-500' : ''}`}>Auto refresh token (with refresh URL)</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically refresh your token using the refresh URL when it expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}

View File

@@ -66,7 +66,6 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
tokenQueryKey: 'access_token',
reuseToken: false
}
})
);

View File

@@ -6,7 +6,7 @@ import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/
const TokenSection = ({ title, token }) => {
if (!token) return null;
const [isExpanded, setIsExpanded] = useState(false);
const [decodedToken, setDecodedToken] = useState(null);
const [copied, setCopied] = useState(false);
@@ -33,18 +33,18 @@ const TokenSection = ({ title, token }) => {
return (
<div className="mb-2 border dark:border-gray-700 rounded-lg overflow-hidden">
<div
<div
className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-2 w-full">
{isExpanded ?
<IconChevronDown size={18} className="text-gray-500" /> :
{isExpanded ?
<IconChevronDown size={18} className="text-gray-500" /> :
<IconChevronRight size={18} className="text-gray-500" />
}
<div className="flex flex-row justify-between w-full">
<h3 className="text-sm font-medium">{title}</h3>
{decodedToken?.exp && <ExpiryTimer expiresIn={decodedToken?.exp}/>}
<h3 className="text-sm font-medium">{title}</h3>
{decodedToken?.exp && <ExpiryTimer expiresIn={decodedToken?.exp} />}
</div>
</div>
</div>
@@ -57,8 +57,8 @@ const TokenSection = ({ title, token }) => {
className="p-1 bg-indigo-100 dark:hover:bg-indigo-200 rounded"
title="Copy token"
>
{copied ?
<IconCheck size={16} className="text-green-700" /> :
{copied ?
<IconCheck size={16} className="text-green-700" /> :
<IconCopy size={16} className="text-gray-500" />
}
</button>
@@ -113,11 +113,10 @@ const ExpiryTimer = ({ expiresIn }) => {
return (
<div
className={`text-xs px-2 py-1 rounded-full ${
timeLeft <= 30
className={`text-xs px-2 py-1 rounded-full min-w-[120px] text-center ${timeLeft <= 30
? "bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400"
: "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
}`}
}`}
>
{timeLeft > 0 ? `Expires in ${formatExpiryTime(timeLeft)}` : `Expired`}
</div>
@@ -138,16 +137,17 @@ const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun })
<TokenSection title="Access Token" token={creds.access_token} />
<TokenSection title="Refresh Token" token={creds.refresh_token} />
<TokenSection title="ID Token" token={creds.id_token} />
{(creds.token_type || creds.scope) ? <div className="mt-3 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg text-xs">
<div className="grid grid-cols-2 gap-2">
{creds.token_type ? <div className="flex items-center space-x-1">
<span className="font-medium">Token Type:</span>
<span className="text-gray-600 dark:text-gray-300">{creds.token_type}</span>
</div> : null}
{creds?.scope ? <div className="flex items-center space-x-1">
<span className="font-medium">Scope:</span>
<span className="text-gray-600 dark:text-gray-300">{creds.scope}</span>
{creds?.scope ? <div className="flex items-center space-x-1 min-w-0">
<span className="font-medium flex-shrink-0">Scope:</span>
<span className="text-gray-600 dark:text-gray-300 truncate" title={creds.scope}>
{creds.scope}
</span>
</div> : null}
</div>
</div> : null}

View File

@@ -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
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefresh', false)}
onChange={(e) => handleChange('autoRefresh', e.target.checked)}
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">Settings</span>
</div>
{/* Automatically Fetch Token */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoFetchToken)}
onChange={(e) => handleChange('autoFetchToken', e.target.checked)}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Automatically fetch token if not found</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when you try to access a resource and don't have one.
</span>
</div>
</div>
</div>
{/* Auto Refresh Token (With Refresh URL) */}
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={Boolean(autoRefreshToken)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isAutoRefreshDisabled}
/>
<label className={`block min-w-[140px] ${isAutoRefreshDisabled ? 'text-gray-500' : ''}`}>Auto refresh token (with refresh URL)</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically refresh your token using the refresh URL when it expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button onClick={handleFetchOauth2Credentials} className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}>
Get Access Token{fetchingToken? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}

View File

@@ -12,12 +12,49 @@ import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import OAuth2 from './OAuth2/index';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _item) => {
let path = [];
let item = findItemInCollection(collection, _item?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const collectionRoot = get(collection, 'root', {});
const collectionAuth = get(collectionRoot, 'request.auth');
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
@@ -46,11 +83,12 @@ const Auth = ({ item, collection }) => {
return <ApiKeyAuth collection={collection} item={item} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from the Collection: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(collectionAuth?.mode)}</div>
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);

View File

@@ -0,0 +1,24 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
font-family: Inter, sans-serif !important;
.arrow {
opacity: 0.5;
}
&.request {
color: ${(props) => props.theme.colors.text.green};
}
&.response {
color: ${(props) => props.theme.colors.text.purple};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import forOwn from 'lodash/forOwn';
import { safeStringifyJSON } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const RunnerTimeline = ({ request, response }) => {
const requestHeaders = [];
const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
request = request || {};
response = response || {};
forOwn(request.headers, (value, key) => {
requestHeaders.push({
name: key,
value
});
});
let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
return (
<StyledWrapper className="pb-4 w-full">
<div>
<pre className="line request font-bold">
<span className="arrow">{'>'}</span> {request.method} {request.url}
</pre>
{requestHeaders.map((h) => {
return (
<pre className="line request" key={h.name}>
<span className="arrow">{'>'}</span> {h.name}: {h.value}
</pre>
);
})}
{requestData ? (
<pre className="line request">
<span className="arrow">{'>'}</span> data{' '}
<pre className="text-sm flex flex-wrap whitespace-break-spaces">{requestData}</pre>
</pre>
) : null}
</div>
<div className="mt-4">
<pre className="line response font-bold">
<span className="arrow">{'<'}</span> {response.status} - {response.statusText}
</pre>
{responseHeaders.map((h) => {
return (
<pre className="line response" key={h[0]}>
<span className="arrow">{'<'}</span> {h[0]}: {h[1]}
</pre>
);
})}
</div>
</StyledWrapper>
);
};
export default RunnerTimeline;

View File

@@ -1,11 +1,115 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.timeline-event {
border-bottom: 1px solid ${(props) => props.theme.colors.text.muted};
padding: 8px 0;
cursor: pointer;
}
.timeline-event-content {
background: ${(props) => props.theme.requestTabs.bg};
border-radius: 4px;
padding: 12px;
margin-top: 0.5rem;
}
.timeline-event-header {
color: ${(props) => props.theme.text};
}
.method-label {
font-weight: 600;
}
.status-code {
font-weight: 600;
}
.url-text {
color: ${(props) => props.theme.colors.text.muted};
font-size: 0.875rem;
margin-top: 0.25rem;
}
.timestamp {
color: ${(props) => props.theme.colors.text.muted};
font-size: 0.875rem;
}
.meta-info {
color: ${(props) => props.theme.colors.text.muted};
font-size: 0.875rem;
}
.oauth-section {
.oauth-header {
display: flex;
align-items: center;
color: ${(props) => props.theme.text};
font-weight: 600;
span {
margin-left: 0.5rem;
}
}
}
.tabs-switcher {
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
margin-bottom: 16px;
button {
position: relative;
padding: 8px 16px;
color: ${(props) => props.theme.colors.text.muted};
&.active {
color: ${(props) => props.theme.tabs.active.color};
&:after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.tabs.active.border};
}
}
}
}
.network-logs {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.text};
border-radius: 4px;
padding: 8px;
}
.oauth-request-item-content {
background: ${(props) => props.theme.requestTabs.bg};
border-radius: 4px;
padding: 12px;
margin-top: 0.5rem;
}
.collapsible-section {
margin-bottom: 12px;
.section-header {
cursor: pointer;
padding: 8px 0;
&:hover {
opacity: 0.8;
}
}
}
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
font-family: Inter, sans-serif !important;
font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
.arrow {
opacity: 0.5;
@@ -19,6 +123,35 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.purple};
}
}
.request-label {
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
background: ${(props) => props.theme.requestTabs.bg};
}
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
}
}
`;
export default StyledWrapper;

View File

@@ -1,61 +1,615 @@
import React from 'react';
import forOwn from 'lodash/forOwn';
import { safeStringifyJSON } from 'utils/common';
import React, { useState } from 'react';
import StyledWrapper from './StyledWrapper';
import QueryResult from '../QueryResult/index';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
import { get } from 'lodash';
const iconv = require('iconv-lite');
const Timeline = ({ request, response }) => {
const requestHeaders = [];
const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
const methodColors = {
GET: 'text-green-500',
POST: 'text-blue-500',
PUT: 'text-yellow-500',
DELETE: 'text-red-500',
PATCH: 'text-purple-500',
OPTIONS: 'text-gray-500',
HEAD: 'text-gray-500',
};
request = request || {};
response = response || {};
const statusColor = (statusCode) => {
if (statusCode >= 200 && statusCode < 300) {
return 'text-green-500';
} else if (statusCode >= 300 && statusCode < 400) {
return 'text-yellow-500';
} else if (statusCode >= 400 && statusCode < 600) {
return 'text-red-500';
} else {
return 'text-gray-500';
}
};
forOwn(request.headers, (value, key) => {
requestHeaders.push({
name: key,
value
});
});
const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
if (authMode !== 'inherit') return null;
let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
uid: collection.uid,
auth: collectionAuth
};
// Get path from collection to item
let path = [];
let currentItem = findItemInCollection(collection, item?.uid);
while (currentItem) {
path.unshift(currentItem);
currentItem = findParentItemInCollection(collection, currentItem?.uid);
}
// Check folders in reverse to find the closest auth configuration
for (let i of [...path].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
uid: i.uid,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const Timeline = ({ collection, item, width }) => {
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
// Filter timeline entries based on new rules
const combinedTimeline = ([...(collection.timeline || [])]).filter(obj => {
// Always show entries for this item
if (obj.itemUid === item.uid) return true;
// For OAuth2 entries, also show if auth is inherited
if (obj.type === 'oauth2' && authSource) {
if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true;
if (authSource.type === 'collection' && !obj.folderUid) return true;
}
return false;
}).sort((a, b) => b.timestamp - a.timestamp);
const [openSections, setOpenSections] = useState(() =>
combinedTimeline.map((_, index) => index === 0)
);
return (
<StyledWrapper className="pb-4 w-full">
<div>
<pre className="line request font-bold">
<span className="arrow">{'>'}</span> {request.method} {request.url}
</pre>
{requestHeaders.map((h) => {
<StyledWrapper
className="pb-4 w-full"
style={{ maxWidth: width - 40, overflowWrap: 'break-word' }}
>
{combinedTimeline.map((event, index) => {
const isOpen = openSections[index];
const toggleOpen = () => {
setOpenSections((prevState) => {
const newState = [...prevState];
newState[index] = !newState[index];
return newState;
});
};
if (event.type === 'request') {
const { request, response, timestamp } = event.data;
return (
<pre className="line request" key={h.name}>
<span className="arrow">{'>'}</span> {h.name}: {h.value}
</pre>
<div key={index} className="timeline-event border-b border-gray-700 py-2">
<div
className="timeline-event-header cursor-pointer"
onClick={toggleOpen}
>
<div className="flex justify-between items-center min-w-0 gap-2">
<div className="flex items-center space-x-2 min-w-0 flex-1">
<div className="flex items-center flex-shrink-0">
<span
className={`${
methodColors[request.method?.toUpperCase()] || 'text-white'
} font-bold`}
>
{request.method?.toUpperCase()}
</span>{' '}
</div>
<span
className={`${
statusColor(response.status || request.statusCode) || 'text-white'
} font-bold`}
>
{response.status || request.statusCode}{' '}
{response.statusText || ''}
</span>
<div className="flex items-center flex-shrink-0 space-x-2">
{response.duration && (
<span className="text-sm text-gray-400">
{response.duration}ms
</span>
)}
{response.size && (
<span className="text-sm text-gray-400">
{response.size}B
</span>
)}
{response.state && (
<span className="text-sm text-gray-400">
{response.state}
</span>
)}
</div>
</div>
<span className="text-sm text-gray-400 flex-shrink-0 overflow-hidden text-ellipsis whitespace-nowrap" style={{ minWidth: '120px', maxWidth: '200px' }}>
{new Date(timestamp).toLocaleString()}
</span>
</div>
<div className="truncate text-sm text-[#9b9b9b] mt-1">{request.url}</div>
</div>
{isOpen && (
<div className="timeline-event-content ml-4 mt-2">
<RenderRequestResponse
request={request}
response={response}
item={item}
collection={collection}
width={width}
/>
</div>
)}
</div>
);
})}
{requestData ? (
<pre className="line request">
<span className="arrow">{'>'}</span> data{' '}
<pre className="text-sm flex flex-wrap whitespace-break-spaces">{requestData}</pre>
</pre>
) : null}
</div>
<div className="mt-4">
<pre className="line response font-bold">
<span className="arrow">{'<'}</span> {response.status} - {response.statusText}
</pre>
{responseHeaders.map((h) => {
} else if (event.type === 'oauth2') {
const { data } = event;
const { debugInfo, fetchedAt } = data;
const flattenedRequests = flattenRequests(debugInfo);
return (
<pre className="line response" key={h[0]}>
<span className="arrow">{'<'}</span> {h[0]}: {h[1]}
</pre>
<div key={index} className="timeline-event border-b border-gray-700 py-2">
<div
className="timeline-event-header cursor-pointer flex items-center"
onClick={toggleOpen}
>
<div className="flex items-center">
<span>{isOpen ? '▼' : '▶'}</span>
<span className="ml-2 font-bold">OAuth2.0 Calls</span>
</div>
</div>
{isOpen && (
<div className="ml-4 mt-2">
{flattenedRequests && flattenedRequests.length > 0 ? (
flattenedRequests.map((data, idx) => (
<OAuthRequestItem
key={idx}
request={data}
item={item}
collection={collection}
width={width - 50}
/>
))
) : (
<div>No debug information available.</div>
)}
</div>
)}
</div>
);
})}
</div>
}
return null;
})}
</StyledWrapper>
);
};
export default Timeline;
const flattenRequests = (requests, level = 0) => {
let flatList = [];
requests.forEach((request) => {
flatList.push({ ...request, isSubRequest: level > 0 });
if (request.requests && request.requests.length > 0) {
flatList = flatList.concat(flattenRequests(request.requests, level + 1));
}
});
return flatList;
};
// Helper function to process dataBuffer
const processDataBuffer = (dataBuffer) => {
if (dataBuffer) {
try {
let buffer;
if (Buffer.isBuffer(dataBuffer)) {
buffer = dataBuffer;
} else if (typeof dataBuffer === 'string') {
buffer = Buffer.from(dataBuffer, 'base64');
} else if (dataBuffer instanceof Uint8Array || Array.isArray(dataBuffer)) {
buffer = Buffer.from(dataBuffer);
} else {
return JSON.stringify(dataBuffer);
}
const dataRaw = iconv.decode(buffer, 'utf-8');
const formattedData = dataRaw.replace(/^\uFEFF/, '');
return formattedData;
} catch (error) {
console.error('Error processing dataBuffer:', error);
return '';
}
}
return null;
};
const RenderRequestResponse = ({ request, response, item, collection, width }) => {
const [activeTab, setActiveTab] = useState('request');
const [isRequestHeadersOpen, setIsRequestHeadersOpen] = useState(false);
const [isResponseHeadersOpen, setIsResponseHeadersOpen] = useState(false);
const [isRequestCookiesOpen, setIsRequestCookiesOpen] = useState(false);
const [isResponseCookiesOpen, setIsResponseCookiesOpen] = useState(false);
const [isRequestBodyOpen, setIsRequestBodyOpen] = useState(false);
const [isResponseBodyOpen, setIsResponseBodyOpen] = useState(false);
const requestHeaders = request.headers || request.requestHeaders || {};
const responseHeaders = response.headers || response.responseHeaders || {};
const {
cookies: requestCookies,
headers: filteredRequestHeaders,
} = separateCookiesAndHeaders(requestHeaders);
const {
cookies: responseCookies,
headers: filteredResponseHeaders,
} = separateCookiesAndHeaders(responseHeaders);
const showNetworkLogs = response.timeline && response.timeline.length > 0;
return (
<div className="text-sm overflow-hidden">
{/* Tabs */}
<div className="tabs-switcher flex mb-4">
<button
className={`mr-4 ${activeTab === 'request' ? 'active' : 'text-gray-400'}`}
onClick={() => setActiveTab('request')}
>
Request
</button>
<button
className={`mr-4 ${activeTab === 'response' ? 'active' : 'text-gray-400'}`}
onClick={() => setActiveTab('response')}
>
Response
</button>
{showNetworkLogs && (
<button
className={`${activeTab === 'networkLogs' ? 'active' : 'text-gray-400'}`}
onClick={() => setActiveTab('networkLogs')}
>
Network Logs
</button>
)}
</div>
{/* Tab Content */}
<div className="tab-content">
{/* Request Tab */}
{activeTab === 'request' && (
<div>
{/* Method and URL */}
<div className="mb-4">
<span className={`${methodColors[request.method.toUpperCase()] || 'text-white'} font-bold`}>
{request.method.toUpperCase()}
</span>{' '}
<span>{request.url}</span>
</div>
{/* Headers */}
<div className="collapsible-section">
<div className="section-header" onClick={() => setIsRequestHeadersOpen(!isRequestHeadersOpen)}>
<span className="font-bold">
{isRequestHeadersOpen ? '▼' : '▶'} Headers
{filteredRequestHeaders && Object.keys(filteredRequestHeaders).length > 0 &&
<sup className="ml-1 font-medium">({Object.keys(filteredRequestHeaders).length})</sup>
}
</span>
</div>
{isRequestHeadersOpen && (
<div className="mt-2">
{filteredRequestHeaders && Object.keys(filteredRequestHeaders).length > 0
? renderHeaders(filteredRequestHeaders)
: <div className="text-gray-500">No Headers found</div>
}
</div>
)}
</div>
{/* Cookies */}
<div className="collapsible-section">
<div className="section-header" onClick={() => setIsRequestCookiesOpen(!isRequestCookiesOpen)}>
<span className="font-bold">
{isRequestCookiesOpen ? '▼' : '▶'} Cookies
{requestCookies && Object.keys(requestCookies).length > 0 &&
<sup className="ml-1 font-medium">({Object.keys(requestCookies).length})</sup>
}
</span>
</div>
{isRequestCookiesOpen && (
<div className="mt-2">
{requestCookies && Object.keys(requestCookies).length > 0
? renderHeaders(requestCookies)
: <div className="text-gray-500">No Cookies found</div>
}
</div>
)}
</div>
{/* Body */}
<div className="collapsible-section">
<div className="section-header" onClick={() => setIsRequestBodyOpen(!isRequestBodyOpen)}>
<span className="font-bold">
{isRequestBodyOpen ? '▼' : '▶'} Body
</span>
</div>
{isRequestBodyOpen && (
<div className="mt-2">
{request.data || request.dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResult
item={item}
collection={collection}
width={width}
data={request.data}
dataBuffer={request.dataBuffer}
headers={request.headers}
error={request.error}
key={item.filename}
/>
</div>
) : (
<div className="text-gray-500">No Body found</div>
)}
</div>
)}
</div>
</div>
)}
{/* Response Tab */}
{activeTab === 'response' && (
<div>
{/* Status */}
<div className="mb-4">
<span className={`${statusColor(response.status || request.statusCode) || 'text-white'} font-bold`}>
{response.status || request.statusCode}
</span>{' '}
<span>{response.statusText || request.statusText || ''}</span>
{response.duration && <span className="text-sm text-gray-400 ml-2">{response.duration}ms</span>}
{response.size && <span className="text-sm text-gray-400 ml-2">{response.size}B</span>}
</div>
{/* Headers */}
<div className="collapsible-section">
<div className="section-header" onClick={() => setIsResponseHeadersOpen(!isResponseHeadersOpen)}>
<span className="font-bold">
{isResponseHeadersOpen ? '▼' : '▶'} Headers
{filteredResponseHeaders && Object.keys(filteredResponseHeaders).length > 0 &&
<sup className="ml-1 font-medium">({Object.keys(filteredResponseHeaders).length})</sup>
}
</span>
</div>
{isResponseHeadersOpen && (
<div className="mt-2">
{filteredResponseHeaders && Object.keys(filteredResponseHeaders).length > 0
? renderHeaders(filteredResponseHeaders)
: <div className="text-gray-500">No Headers found</div>
}
</div>
)}
</div>
{/* Cookies */}
<div className="collapsible-section">
<div className="section-header" onClick={() => setIsResponseCookiesOpen(!isResponseCookiesOpen)}>
<span className="font-bold">
{isResponseCookiesOpen ? '▼' : '▶'} Cookies
{responseCookies && Object.keys(responseCookies).length > 0 &&
<sup className="ml-1 font-medium">({Object.keys(responseCookies).length})</sup>
}
</span>
</div>
{isResponseCookiesOpen && (
<div className="mt-2">
{responseCookies && Object.keys(responseCookies).length > 0
? renderHeaders(responseCookies)
: <div className="text-gray-500">No Cookies found</div>
}
</div>
)}
</div>
{/* Body */}
<div className="collapsible-section">
<div className="section-header" onClick={() => setIsResponseBodyOpen(!isResponseBodyOpen)}>
<span className="font-bold">
{isResponseBodyOpen ? '▼' : '▶'} Body
</span>
</div>
{isResponseBodyOpen && (
<div className="mt-2">
{response.data || response.dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResult
item={item}
collection={collection}
width={width}
data={response.data}
dataBuffer={response.dataBuffer}
headers={response.headers}
error={response.error}
key={item.filename}
/>
</div>
) : (
<div className="text-gray-500">No Body found</div>
)}
</div>
)}
</div>
</div>
)}
{/* Network Logs Tab */}
{activeTab === 'networkLogs' && showNetworkLogs && (
<div className="bg-black/5 text-white p-2 network-logs rounded overflow-auto h-96">
<pre className="whitespace-pre-wrap">
{response.timeline.map((entry, index) => (
<NetworkLogsEntry key={index} entry={entry} />
))}
</pre>
</div>
)}
</div>
</div>
);
};
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 (
<div className={`${className}`}>
<div>{message}</div>
</div>
);
};
// Helper functions
const renderHeaders = (data) => {
if (Array.isArray(data)) {
return (
<div className="mt-2 text-sm">
{data.map((header, index) => (
<div key={index} className="flex mb-2">
<div className="w-1/3 font-bold">{header.name}:</div>
<div className="w-2/3">{String(header.value)}</div>
</div>
))}
</div>
);
} else {
return (
<div className="mt-2 text-sm">
{Object.entries(data).map(([key, value], index) => (
<div key={index} className="flex mb-2">
<div className="w-1/3 font-bold">{key}:</div>
<div className="w-2/3">{String(value)}</div>
</div>
))}
</div>
);
}
};
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 (
<div className="border-b border-gray-700 py-2">
<div className="oauth-request-item-header cursor-pointer" onClick={toggleOpen}>
<div className="flex justify-between items-center min-w-0">
<div className="flex items-center space-x-2 min-w-0">
<div className="flex items-center flex-shrink-0">
<span className={`${methodColors[request.method?.toUpperCase()] || 'text-white'} font-bold`}>
{request.method?.toUpperCase()}
</span>{' '}
</div>
<span className={`${statusColor(request.statusCode) || 'text-white'} font-bold`}>
{request.statusCode}
{request.statusText || ''}
</span>
<div className="flex items-center flex-shrink-0 space-x-2">
{isSubRequest && <span className="request-label">API Request</span>}
{isImage && <span className="request-label">Image</span>}
{request.duration && <span className="text-sm text-gray-400">{request.duration}ms</span>}
{request.size && <span className="text-sm text-gray-400">{request.size}B</span>}
{request.state && <span className="text-sm text-gray-400">{request.state}</span>}
</div>
</div>
</div>
<div className="truncate text-sm text-[#9b9b9b] mt-1">{request.url}</div>
</div>
{isOpen && (
<div className="oauth-request-item-content mt-2 text-sm">
<RenderRequestResponse
request={request}
response={request}
item={item}
collection={collection}
width={width}
/>
</div>
)}
</div>
);
};
export default Timeline;

View File

@@ -54,7 +54,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <ResponseHeaders headers={response.headers} />;
}
case 'timeline': {
return <Timeline request={item.requestSent} response={item.response} />;
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
}
case 'tests': {
return <TestResults results={item.testResults} assertionResults={item.assertionResults} />;

View File

@@ -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 <ResponseHeaders headers={headers} />;
}
case 'timeline': {
return <Timeline request={requestSent} response={responseReceived} />;
return <RunnerTimeline request={requestSent} response={responseReceived} />;
}
case 'tests': {
return <TestResults results={testResults} assertionResults={assertionResults} />;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()}`)}
}
`;

View File

@@ -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()}`)}
}
`;

View File

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