Compare commits

...

76 Commits

Author SHA1 Message Date
ramki-bruno
5af8034c74 Added Playwright-codegen setup 2025-04-01 17:58:35 +05:30
Anoop M D
bd25097e44 Rename SECURITY.md to security.md 2025-03-31 14:03:06 +05:30
ganesh-bruno
3f140e818f replace example.com to usebruno website 2025-03-31 14:01:13 +05:30
lohxt1
dbba22131c fix: proxy and certs not being used for oauth2 calls 2025-03-27 22:56:00 +05:30
lohit
8908828af0 Merge pull request #4353 from naman-bruno/bugfix/system-proxy-agent
Fix: system proxy agent
2025-03-27 19:31:21 +05:30
lohit
20c9e1d406 removed the domain protocol check 2025-03-27 19:28:00 +05:30
naman-bruno
741576526d Fix: logic and design 2025-03-27 18:24:45 +05:30
naman-bruno
b928ec112e add: protocol verification for certificate domain 2025-03-27 16:36:42 +05:30
naman-bruno
559026b65c Fix: right Agent for system proxy 2025-03-27 16:36:22 +05:30
naman-bruno
eb6fef63b3 Removed log 2025-03-26 20:30:39 +05:30
naman-bruno
1b767f8d26 fix: params 2025-03-26 20:30:39 +05:30
naman-bruno
3b061cda89 Fix: agentOptions not passing properly to baseAgent 2025-03-26 20:30:39 +05:30
ganesh
e0d7bb50f3 2025-03-26 16:27:33 +05:30
ramki-bruno
20e8e9167f Perf improvements in Response-preview with useMemo 2025-03-25 23:07:47 +05:30
lohit
268ede869d handle oauth2 timeline response, remove dataBuffer from debugInfo obj (#4330) 2025-03-25 22:28:29 +05:30
ramki-bruno
8b8ddaf31b Revert "Fix: Prettify JSON for Res-preview without parsing to avoid JS specific roundings"
This reverts commit 56581b3641.
2025-03-25 21:58:18 +05:30
lohit
f16fbeade7 Merge pull request #4328 from lohxt1/main
revert serialization of debugInfo data
2025-03-25 19:52:35 +05:30
lohxt1
d27677030d revert serialization of debugInfo data 2025-03-25 19:50:06 +05:30
pooja-bruno
578e912faf fix: codemirror lint errors (#4321)
* fix: codemirror lint errors
* chore: added comments for await lint fix and removed the indent config prop that was not needed

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-03-25 19:44:22 +05:30
lohit
bd8c6caa39 Merge pull request #4325 from lohxt1/main
oauth2 fixes
2025-03-25 18:33:10 +05:30
lohxt1
7635230c88 oauth2 fixes 2025-03-25 18:29:44 +05:30
lohit
eb0d746082 Merge pull request #4324 from naman-bruno/bugfix/redirect-proxy
Fix: Redirect not working on proxy
2025-03-25 18:05:17 +05:30
naman-bruno
61b3853390 overwrite maxredirect on request obj 2025-03-25 18:01:40 +05:30
naman-bruno
76485cdd56 reseted max redirect counts 2025-03-25 17:56:31 +05:30
lohit
3d5401a8db Merge pull request #4323 from lohxt1/timeline_fixes
oauth2 fixes
2025-03-25 17:53:00 +05:30
lohxt1
6faecc2874 setting the default value for auto_refresh_token to false 2025-03-25 17:46:06 +05:30
lohit
4650ca40c1 Merge pull request #4322 from lohxt1/timeline_fixes
oauth2 fixes and improvements
2025-03-25 17:19:27 +05:30
lohxt1
14ee26557a query stringify request payload data before the oauth2 token url request 2025-03-25 17:09:36 +05:30
lohxt1
8e873013a9 oauth2 fixes 2025-03-25 15:42:58 +05:30
Pooja Belaramani
b0caf46406 fix: request run crash 2025-03-25 15:31:28 +05:30
lohit
a0926c4064 Merge pull request #4309 from lohxt1/timeline_fixes
oauth2 related fixes
2025-03-25 12:22:30 +05:30
lohxt1
b795b1c5ce fixes 2025-03-25 12:03:38 +05:30
lohxt1
61ba5f5c39 check if token is expired only if expires_in prop is present, clear response and clear timeline are 2 different things, clear redux state after clearing oauth2 credentials cache 2025-03-24 22:49:21 +05:30
lohxt1
f177287fb6 fix folder settings auth selector comp prop 2025-03-24 19:43:56 +05:30
anusree-bruno
ef929e78f3 Create SECURITY.md (#4266) 2025-03-24 17:33:51 +05:30
lohxt1
274a55e257 folder level oauth2 save fn fix 2025-03-24 15:46:31 +05:30
Ryan
9e24223085 Delete .github/repository-metadata.yml 2025-03-21 19:47:23 +05:30
rreyn-bruno
3cd3d8bdcc Remove sponsorship content from repository 2025-03-21 19:47:23 +05:30
lohit
eed3f2ff4c Merge pull request #4300 from lohxt1/oauth2_fixes
timeline ui fixes, oauth2 validation fixes
2025-03-21 15:58:32 +05:30
lohxt1
b09b4b1d17 timeline ui fixes, oauth2 validation fixes 2025-03-21 15:56:02 +05:30
lohit
cd9c667e8a Merge pull request #4297 from lohxt1/oauth2_fixes
oauth2 fixes, ui validations, timeline ui updates
2025-03-21 00:44:54 +05:30
lohxt1
2675e79dbd oauth2 fixes, ui validations, timeline updates (wip) 2025-03-21 00:41:07 +05:30
lohit
ef94f55c82 Merge pull request #4296 from lohxt1/version_upgrade_2.0.0
chore: version upgrade to 2.0.0
2025-03-20 20:33:43 +05:30
lohxt1
926919524b chore: version upgrade to 2.0.0 2025-03-20 20:31:53 +05:30
lohit
087f691544 Merge pull request #3867 from usebruno/feat/oauth2-improvements
OAuth2 Revamp
2025-03-20 20:22:28 +05:30
lohxt1
3a81ebf0e2 oauth2 fixes 2025-03-20 20:11:12 +05:30
lohxt1
3ffaaab8f3 Merge remote-tracking branch 'upstream/main' into feat/oauth2-improvements 2025-03-20 19:38:54 +05:30
lohxt1
5a98da2031 oauth2 fixes 2025-03-20 19:27:14 +05:30
lohxt1
2e4014863f fix the validations for oauth2 json_to_bru 2025-03-20 17:06:47 +05:30
lohxt1
d3fcb42a8f timeline ui updates wip 2025-03-17 14:09:36 +05:30
lohxt1
51be153527 fix bruno-electron unit tests 2025-03-16 14:28:21 +05:30
lohxt1
5728b7c8a8 fix bruno-lang unit tests 2025-03-16 14:24:04 +05:30
lohxt1
71b6907c31 fix bruno-cli unit tests 2025-03-16 14:11:19 +05:30
lohxt1
eead96ca26 Merge remote-tracking branch 'upstream/main' into feat/oauth2-improvements 2025-03-16 14:02:12 +05:30
lohxt1
f99e8770f0 ~ reverting the bruno-electron ipc-network files refactoring work to keep the diff minimal 2025-03-16 13:37:59 +05:30
naman-bruno
8cda05c431 updated timeline to show body in oauth (#4168) 2025-03-06 17:04:07 +05:30
naman-bruno
7af7ff92bf Fix: redirect to relative path (#4167) 2025-03-06 14:26:02 +05:30
naman-bruno
3169e6cdf4 Oauth2 folder (#4105) 2025-03-06 11:03:34 +05:30
pooja-bruno
4e88cbf318 feat: add refresh url for oauth2 (#4028) 2025-02-18 17:24:56 +05:30
lohit
413b121ce1 Merge pull request #3989 from lohxt1/feat/oauth2__improvements
oauth2 improvements
2025-02-11 12:33:24 +05:30
lohit
90dff3d1e1 Merge branch 'feat/oauth2-improvements' into feat/oauth2__improvements 2025-02-11 12:32:04 +05:30
lohxt1
3fc0b0a668 oauth2 improvements - collection import default type 2025-02-11 12:27:58 +05:30
lohxt1
b5e53ec25c include oauth2 request data along with headers in the access token url call 2025-02-10 20:20:40 +05:30
lohxt1
01a62d66cc oauth2 postman import fix and include client certs and proxy config while fetching access token 2025-02-05 19:06:23 +05:30
lohxt1
f668e93f52 oauth2 postman import fix and include client certs and proxy config while fetching access token 2025-02-05 16:06:41 +05:30
lohit
c5eeb190d3 oauth2 updates (#3876)
~ changed tokenPrefix to tokenHeaderPrefix
~ updated the logic for token timer component
2025-01-24 19:39:29 +05:30
lohit
1d1e701ccb oauth2 workflow improvements (#3874)
~ basic auth credentials should be assigned to `request.basicAuth` instead `request.auth` object
~ added credentials_placement option, fixed headers issue client credentials flow
~ cache input field values when grant type select box value changes
~ updated logic for - cache input field values when grant type select box value changes
~ updated token expiry timer component logic
2025-01-24 18:44:02 +05:30
lohit
f38c7ae03a oauth2 ui/ux improvements (#3868) 2025-01-23 22:06:50 +05:30
Anoop M D
8f754142c7 Merge branch 'pr-2077' into feat/oauth2-improvements 2025-01-23 16:45:51 +05:30
Mateusz Pietryga
3bd8f09c88 feat: OAuth2 - Supported at the collection level (#1704) 2024-09-23 21:59:16 +02:00
Mateusz Pietryga
dd9cb21f8c feat: OAuth2 - UI for OAuth2 Credentials independent of the Request Output pane
fix: typo - rename OAuth2PasswordCredentials component
fix: typo - Use the same name for AuthMode - OAuth 2.0 in collection and request level
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
2064cc88ab feat: OAuth2 - automatically handle Bearer token type only
According to RFC6749 Section 7.1, The client MUST NOT use an access token
if it does not understand the token type.
At this point bruno only understands 'bearer' token_type.
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
d982e35a17 feat: OAuth2 - Do not make axios request when executing collection level Get Access Token action
The actual the authorization request is now part of request preparation, and its response is returned for post-request script processing.
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
4afcd44216 feat: OAuth2 - Include resolved authorization details in req object to be usable by scripts
The new variable 'credentials' is now available in 'req' object. It is added automatically during request preparation if oauth2 method is used and is value is either evaluated or retrieved from collection oauth2 cache.
2024-09-23 21:59:16 +02:00
Mateusz Pietryga
63252d3ee2 feat: OAuth2 - Store authorization information
Results of oauth2 authorization flow (i.e. access_token but also refresh_token, id_token, scope or any other information returned from token request) are stored in a collection specific cache. It is persisted in the file system, and will be automatically reused when executing requests until the cache is purged (using Clear Cache button available in all related views).
2024-09-23 20:50:41 +02:00
Mateusz Pietryga
22a9502976 fix: OAuth2 - auth is successful but token endpoint is returned instead of api endpoint (#1999)
Setting oauth2 authorization no longer equals overwriting user-specified data in a request. The pre-requests made to obtain oauth2 access_token are now separated from actual API request.
2024-09-23 20:50:37 +02:00
117 changed files with 5969 additions and 1502 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: helloanoop

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

4
package-lock.json generated
View File

@@ -25025,7 +25025,7 @@
},
"packages/bruno-app": {
"name": "@usebruno/app",
"version": "1.39.0",
"version": "2.0.0",
"dependencies": {
"@babel/preset-env": "^7.26.0",
"@fontsource/inter": "^5.0.15",
@@ -26276,7 +26276,7 @@
},
"packages/bruno-electron": {
"name": "bruno",
"version": "v1.38.1",
"version": "2.0.0",
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",

View File

@@ -47,6 +47,7 @@
"build:electron:deb": "./scripts/build-electron.sh deb",
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/app",
"version": "1.39.0",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "rsbuild dev",

View File

@@ -95,7 +95,7 @@ const AuthMode = ({ collection }) => {
onModeChange('oauth2');
}}
>
Oauth2
OAuth 2.0
</div>
<div
className="dropdown-item"

View File

@@ -1,120 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
import { clearOauth2Cache } from 'utils/network/index';
import toast from 'react-hot-toast';
const OAuth2AuthorizationCode = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const handleRun = async () => {
dispatch(sendCollectionOauth2Request(collection.uid));
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, state, pkce } = oAuth;
const handleChange = (key, value) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'authorization_code',
callbackUrl,
authorizationUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
state,
pkce,
[key]: value
}
})
);
};
const handlePKCEToggle = (e) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'authorization_code',
callbackUrl,
authorizationUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
state,
pkce: !Boolean(oAuth?.['pkce'])
}
})
);
};
const handleClearCache = (e) => {
clearOauth2Cache(collection?.uid)
.then(() => {
toast.success('cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<div className="flex flex-row w-full gap-4" key="pkce">
<label className="block font-medium">Use PKCE</label>
<input
className="cursor-pointer"
type="checkbox"
checked={Boolean(oAuth?.['pkce'])}
onChange={handlePKCEToggle}
/>
</div>
<div className="flex flex-row gap-4">
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
</div>
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;

View File

@@ -1,33 +0,0 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'
},
{
key: 'accessTokenUrl',
label: 'Access Token URL'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'clientSecret',
label: 'Client Secret',
isSecret: true
},
{
key: 'scope',
label: 'Scope'
},
{
key: 'state',
label: 'State'
}
];
export { inputsConfig };

View File

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

@@ -1,70 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
const OAuth2ClientCredentials = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const handleRun = async () => {
dispatch(sendCollectionOauth2Request(collection.uid));
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const { accessTokenUrl, clientId, clientSecret, scope } = oAuth;
const handleChange = (key, value) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'client_credentials',
accessTokenUrl,
clientId,
clientSecret,
scope,
[key]: value
}
})
);
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
</StyledWrapper>
);
};
export default OAuth2ClientCredentials;

View File

@@ -1,21 +0,0 @@
const inputsConfig = [
{
key: 'accessTokenUrl',
label: 'Access Token URL'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'clientSecret',
label: 'Client Secret',
isSecret: true
},
{
key: 'scope',
label: 'Scope'
}
];
export { inputsConfig };

View File

@@ -1,54 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.8125rem;
.grant-type-mode-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
.dropdown {
width: fit-content;
div[data-tippy-root] {
width: fit-content;
}
.tippy-box {
width: fit-content;
max-width: none !important;
.tippy-content: {
width: fit-content;
max-width: none !important;
}
}
}
.grant-type-label {
width: fit-content;
color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
label {
font-size: 0.8125rem;
}
`;
export default Wrapper;

View File

@@ -1,98 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown } from '@tabler/icons';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
import { updateCollectionAuth, updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections/index';
const GrantTypeSelector = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onGrantTypeChange = (grantType) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType
}
})
);
};
useEffect(() => {
// initialize redux state with a default oauth2 grant type
// authorization_code - default option
!oAuth?.grantType &&
dispatch(
updateCollectionAuthMode({
mode: 'oauth2',
collectionUid: collection.uid
})
);
!oAuth?.grantType &&
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'authorization_code'
}
})
);
}, [oAuth]);
return (
<StyledWrapper>
<label className="block font-medium mb-2">Grant Type</label>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('password');
}}
>
Password Credentials
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('authorization_code');
}}
>
Authorization Code
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('client_credentials');
}}
>
Client Credentials
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default GrantTypeSelector;

View File

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

@@ -1,72 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveCollectionRoot, sendCollectionOauth2Request } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oAuth = get(collection, 'root.request.auth.oauth2', {});
const handleRun = async () => {
dispatch(sendCollectionOauth2Request(collection.uid));
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const { accessTokenUrl, username, password, clientId, clientSecret, scope } = oAuth;
const handleChange = (key, value) => {
dispatch(
updateCollectionAuth({
mode: 'oauth2',
collectionUid: collection.uid,
content: {
grantType: 'password',
accessTokenUrl,
username,
password,
clientId,
clientSecret,
scope,
[key]: value
}
})
);
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;

View File

@@ -1,29 +0,0 @@
const inputsConfig = [
{
key: 'accessTokenUrl',
label: 'Access Token URL'
},
{
key: 'username',
label: 'Username'
},
{
key: 'password',
label: 'Password'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'clientSecret',
label: 'Client Secret',
isSecret: true
},
{
key: 'scope',
label: 'Scope'
}
];
export { inputsConfig };

View File

@@ -1,21 +1,33 @@
import React from 'react';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
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';
const GrantTypeComponentMap = ({collection }) => {
const dispatch = useDispatch();
const save = () => {
dispatch(saveCollectionRoot(collection.uid));
};
let request = collection.draft ? get(collection, 'draft.request', {}) : get(collection, 'root.request', {});
const grantType = get(request, 'auth.oauth2.grantType', {});
const grantTypeComponentMap = (grantType, collection) => {
switch (grantType) {
case 'password':
return <OAuth2PasswordCredentials collection={collection} />;
return <OAuth2PasswordCredentials save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
case 'authorization_code':
return <OAuth2AuthorizationCode collection={collection} />;
return <OAuth2AuthorizationCode save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
case 'client_credentials':
return <OAuth2ClientCredentials collection={collection} />;
return <OAuth2ClientCredentials save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
default:
return <div>TBD</div>;
@@ -24,12 +36,12 @@ const grantTypeComponentMap = (grantType, collection) => {
};
const OAuth2 = ({ collection }) => {
const oAuth = get(collection, 'root.request.auth.oauth2', {});
let request = collection.draft ? get(collection, 'draft.request', {}) : get(collection, 'root.request', {});
return (
<StyledWrapper className="mt-2 w-full">
<GrantTypeSelector collection={collection} />
{grantTypeComponentMap(oAuth?.grantType, collection)}
<GrantTypeSelector request={request} updateAuth={updateCollectionAuth} collection={collection} />
<GrantTypeComponentMap collection={collection} />
</StyledWrapper>
);
};

View File

@@ -25,7 +25,10 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
passphrase: ''
},
validationSchema: Yup.object({
domain: Yup.string().required(),
domain: Yup.string()
.required()
.trim()
.test('not-empty-after-trim', 'Domain is required', value => value && value.trim().length > 0),
type: Yup.string().required().oneOf(['cert', 'pfx']),
certFilePath: Yup.string().when('type', {
is: (type) => type == 'cert',
@@ -45,7 +48,7 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
let relevantValues = {};
if (values.type === 'cert') {
relevantValues = {
domain: values.domain,
domain: values.domain?.trim(),
type: values.type,
certFilePath: values.certFilePath,
keyFilePath: values.keyFilePath,
@@ -53,7 +56,7 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
};
} else {
relevantValues = {
domain: values.domain,
domain: values.domain?.trim(),
type: values.type,
pfxFilePath: values.pfxFilePath,
passphrase: values.passphrase
@@ -127,15 +130,20 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<label className="settings-label" htmlFor="domain">
Domain
</label>
<input
id="domain"
type="text"
name="domain"
placeholder="*.example.org"
className="block textbox non-passphrase-input"
onChange={formik.handleChange}
value={formik.values.domain || ''}
/>
<div className="relative flex items-center">
<div className="absolute left-0 pl-2 text-gray-400 pointer-events-none flex items-center h-full">
https://
</div>
<input
id="domain"
type="text"
name="domain"
placeholder="example.org"
className="block textbox non-passphrase-input !pl-[60px]"
onChange={formik.handleChange}
value={formik.values.domain || ''}
/>
</div>
{formik.touched.domain && formik.errors.domain ? (
<div className="ml-1 text-red-500">{formik.errors.domain}</div>
) : null}

View File

@@ -119,6 +119,6 @@ This documentation supports Markdown formatting! You can use:
- **Bold** and *italic* text
- \`code blocks\` and syntax highlighting
- Tables and lists
- [Links](https://example.com)
- [Links](https://usebruno.com)
- And more!
`;

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}
item={folder}
/>
<GrantTypeComponentMap collection={collection} folder={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

@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content: {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
// color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default Wrapper;

View File

@@ -1,28 +1,63 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import { clearOauth2Cache } from 'utils/network/index';
import toast from 'react-hot-toast';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
authorizationUrl,
accessTokenUrl,
clientId,
clientSecret,
scope,
credentialsPlacement,
state,
pkce,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken
} = oAuth;
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
};
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, state, pkce } = oAuth;
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'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleSave = () => { save(); };
const handleChange = (key, value) => {
dispatch(
@@ -40,7 +75,15 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
state,
scope,
pkce,
[key]: value
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
[key]: value,
}
})
);
@@ -61,30 +104,35 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientSecret,
state,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
pkce: !Boolean(oAuth?.['pkce'])
}
})
);
};
const handleClearCache = (e) => {
clearOauth2Cache(collection?.uid)
.then(() => {
toast.success('cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<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">
Configuration
</span>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
@@ -99,8 +147,33 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
</div>
);
})}
<div className="flex items-center gap-4 w-full" key={`input-credentials-placement`}>
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex flex-row w-full gap-4" key="pkce">
<label className="block font-medium">Use PKCE</label>
<label className="block">Use PKCE</label>
<input
className="cursor-pointer"
type="checkbox"
@@ -108,16 +181,154 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
onChange={handlePKCEToggle}
/>
</div>
<div className="flex flex-row gap-4">
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{
tokenPlacement === 'header' ?
<div className="flex items-center gap-4 w-full" key={`input-token-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenHeaderPrefix'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
/>
</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">
<SingleLineEditor
value={oAuth['tokenQueryKey'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
}
<div className="flex items-center gap-2.5 mt-4 mb-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Advanced Settings
</span>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Refresh Token URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={get(request, 'auth.oauth2.refreshTokenUrl', '')}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange("refreshTokenUrl", val)}
collection={collection}
item={item}
/>
</div>
</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>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={accessTokenUrl} credentialsId={credentialsId} />
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;
export default OAuth2AuthorizationCode;

View File

@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content: {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
// color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default Wrapper;

View File

@@ -1,26 +1,61 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
const OAuth2ClientCredentials = ({ item, collection }) => {
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const oAuth = get(request, 'auth.oauth2', {});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
};
const {
accessTokenUrl,
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken
} = oAuth;
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
const { accessTokenUrl, clientId, clientSecret, scope } = oAuth;
const handleSave = () => { save(); };
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
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'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
@@ -34,6 +69,14 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
[key]: value
}
})
@@ -42,12 +85,21 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<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">
Configuration
</span>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
@@ -62,9 +114,190 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<div className="flex items-center gap-4 w-full" key={`input-credentials-placement`}>
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{
tokenPlacement === 'header' ?
<div className="flex items-center gap-4 w-full" key={`input-token-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenHeaderPrefix'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
/>
</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">
<SingleLineEditor
value={oAuth['tokenQueryKey'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
}
<div className="flex items-center gap-2.5 mt-4 mb-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Advanced Settings
</span>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Refresh Token URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={get(request, 'auth.oauth2.refreshTokenUrl', '')}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange("refreshTokenUrl", val)}
collection={collection}
item={item}
/>
</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.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>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={accessTokenUrl} credentialsId={credentialsId} />
</StyledWrapper>
);
};

View File

@@ -3,18 +3,20 @@ import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown } from '@tabler/icons';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { IconCaretDown, IconKey } from '@tabler/icons';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
import { useState } from 'react';
const GrantTypeSelector = ({ item, collection }) => {
const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const oAuth = get(request, 'auth.oauth2', {});
const [valuesCache, setValuesCache] = useState({
...oAuth
});
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
@@ -24,13 +26,19 @@ const GrantTypeSelector = ({ item, collection }) => {
});
const onGrantTypeChange = (grantType) => {
let updatedValues = {
...valuesCache,
...oAuth,
grantType
};
setValuesCache(updatedValues);
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
grantType
...updatedValues
}
})
);
@@ -46,7 +54,18 @@ const GrantTypeSelector = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
grantType: 'authorization_code'
grantType: 'authorization_code',
accessTokenUrl: '',
username: '',
password: '',
clientId: '',
clientSecret: '',
scope: '',
credentialsPlacement: 'body',
credentialsId: 'credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
tokenQueryKey: 'access_token',
}
})
);
@@ -54,7 +73,14 @@ const GrantTypeSelector = ({ item, collection }) => {
return (
<StyledWrapper>
<label className="block font-medium mb-2">Grant Type</label>
<div className="flex items-center gap-2.5 my-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Grant Type
</span>
</div>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
@@ -89,4 +115,4 @@ const GrantTypeSelector = ({ item, collection }) => {
</StyledWrapper>
);
};
export default GrantTypeSelector;
export default GrantTypeSelector;

View File

@@ -0,0 +1,96 @@
import { useMemo, useState } from "react";
import { useDispatch } from "react-redux";
import toast from 'react-hot-toast';
import { cloneDeep, find } from 'lodash';
import { IconLoader2 } from '@tabler/icons';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import { getAllVariables } from "utils/collections/index";
const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => {
const { uid: collectionUid } = collection;
const dispatch = useDispatch();
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const interpolatedAccessTokenUrl = useMemo(() => {
const variables = getAllVariables(collection, item);
return interpolate(accessTokenUrl, variables);
}, [collection, item, accessTokenUrl]);
const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedAccessTokenUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
const creds = credentialsData?.credentials || {};
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleFetchingToken(true);
try {
const credentials = await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
toggleFetchingToken(false);
if (credentials?.access_token) {
toast.success('token fetched successfully!');
}
else {
toast.error('An error occured while fetching token!');
}
}
catch (error) {
console.error('could not fetch the token!');
console.error(error);
toggleFetchingToken(false);
toast.error('An error occured while fetching token!');
}
}
const handleRefreshAccessToken = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleRefreshingToken(true);
try {
const credentials = await dispatch(refreshOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
toggleRefreshingToken(false);
if (credentials?.access_token) {
toast.success('token refreshed successfully!');
}
else {
toast.error('An error occured while refreshing token!');
}
}
catch(error) {
console.error(error);
toggleRefreshingToken(false);
toast.error('An error occured while refreshing token!');
}
};
const handleClearCache = (e) => {
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
.then(() => {
toast.success('cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<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} /> : ""}
</button>
{creds?.refresh_token ? <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} /> : ""}
</button> : null}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
</div>
)
}
export default Oauth2ActionButtons;

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const Wrapper = styled.div`
ol[role="tree"] {
overflow: hidden;
}
ol[role="group"] span {
line-break: anywhere;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,173 @@
import { find } from "lodash";
import StyledWrapper from "./StyledWrapper";
import { useState, useEffect } from "react";
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
import { getAllVariables } from 'utils/collections/index';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
const TokenSection = ({ title, token }) => {
if (!token) return null;
const [isExpanded, setIsExpanded] = useState(false);
const [decodedToken, setDecodedToken] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (token) {
try {
const parts = token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(atob(parts[1]));
setDecodedToken(payload);
}
} catch (err) {
console.error('Error decoding token:', err);
}
}
}, [token]);
const handleCopy = async (text) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="mb-2 border dark:border-gray-700 rounded-lg overflow-hidden">
<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" /> :
<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} />}
</div>
</div>
</div>
{isExpanded && (
<div className="p-3 text-sm">
<div className="relative group">
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleCopy(token)}
className="p-1 bg-indigo-100 dark:hover:bg-indigo-200 rounded"
title="Copy token"
>
{copied ?
<IconCheck size={16} className="text-green-700" /> :
<IconCopy size={16} className="text-gray-500" />
}
</button>
</div>
<div className="font-mono text-xs bg-gray-50 dark:bg-gray-800 p-2 rounded break-all">
{token}
</div>
</div>
{decodedToken && (
<div className="mt-3">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Decoded Payload</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{Object.entries(decodedToken).map(([key, value]) => (
<div key={key} className="overflow-hidden text-ellipsis">
<span className="font-medium text-xs">{key}: </span>
<span className="text-xs text-gray-600 dark:text-gray-300">
{typeof value === 'object' ? JSON.stringify(value) : value.toString()}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
const formatExpiryTime = (seconds) => {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
};
const ExpiryTimer = ({ expiresIn }) => {
if (!expiresIn) return null;
const calculateTimeLeft = () => Math.max(0, Math.floor(expiresIn - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft);
useEffect(() => {
setTimeLeft(calculateTimeLeft());
const timer = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(timer);
}, [expiresIn]);
return (
<div
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>
);
};
const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun }) => {
const { uid: collectionUid } = collection;
const interpolatedUrl = useMemo(() => {
const variables = getAllVariables(collection, item);
return interpolate(url, variables);
}, [collection, item, url]);
const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
const creds = credentialsData?.credentials || {};
return (
<StyledWrapper className="relative w-auto h-fit mt-2">
{Object.keys(creds)?.length ? (
creds?.error ? (
<pre className="text-red-600 dark:text-red-400">Error fetching token. Check network logs for more details.</pre>
) : (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm">
<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 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}
</div>
)
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">No token found</div>
)}
</StyledWrapper>
);
};
export default Oauth2TokenViewer;

View File

@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content: {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
// color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default Wrapper;

View File

@@ -1,26 +1,62 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
const oAuth = get(request, 'auth.oauth2', {});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
};
const {
accessTokenUrl,
username,
password,
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken
} = oAuth;
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
const { accessTokenUrl, username, password, clientId, clientSecret, scope } = oAuth;
const handleSave = () => { save(); }
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
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'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
@@ -36,6 +72,14 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
[key]: value
}
})
@@ -44,12 +88,21 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<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">
Configuration
</span>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label>
<div className="single-line-editor-wrapper">
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
@@ -64,11 +117,190 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
</div>
);
})}
<button onClick={handleRun} className="submit btn btn-sm btn-secondary w-fit">
Get Access Token
</button>
<div className="flex items-center gap-4 w-full" key={`input-credentials-placement`}>
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{
tokenPlacement === 'header' ?
<div className="flex items-center gap-4 w-full" key={`input-token-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oAuth['tokenHeaderPrefix'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
/>
</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">
<SingleLineEditor
value={oAuth['tokenQueryKey'] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
/>
</div>
</div>
}
<div className="flex items-center gap-2.5 mt-4 mb-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Advanced Settings
</span>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Refresh Token URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={get(request, 'auth.oauth2.refreshTokenUrl', '')}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange("refreshTokenUrl", val)}
collection={collection}
item={item}
/>
</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.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>
<Oauth2ActionButtons item={item} request={request} collection={collection} url={accessTokenUrl} credentialsId={credentialsId} />
</StyledWrapper>
);
};
export default OAuth2AuthorizationCode;
export default OAuth2PasswordCredentials;

View File

@@ -5,17 +5,34 @@ import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
const GrantTypeComponentMap = ({ item, collection }) => {
const dispatch = useDispatch();
const save = () => {
dispatch(saveRequest(item.uid, collection.uid));
};
let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
const grantType = get(request, 'auth.oauth2.grantType', {});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid));
};
const grantTypeComponentMap = (grantType, item, collection) => {
switch (grantType) {
case 'password':
return <OAuth2PasswordCredentials item={item} collection={collection} />;
return <OAuth2PasswordCredentials item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
case 'authorization_code':
return <OAuth2AuthorizationCode item={item} collection={collection} />;
return <OAuth2AuthorizationCode item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
case 'client_credentials':
return <OAuth2ClientCredentials item={item} collection={collection} />;
return <OAuth2ClientCredentials item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
default:
return <div>TBD</div>;
@@ -24,12 +41,12 @@ const grantTypeComponentMap = (grantType, item, collection) => {
};
const OAuth2 = ({ item, collection }) => {
const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
return (
<StyledWrapper className="mt-2 w-full">
<GrantTypeSelector item={item} collection={collection} />
{grantTypeComponentMap(oAuth?.grantType, item, collection)}
<GrantTypeSelector item={item} request={request} updateAuth={updateAuth} collection={collection} />
<GrantTypeComponentMap item={item} collection={collection} />
</StyledWrapper>
);
};

View File

@@ -10,14 +10,51 @@ import NTLMAuth from './NTLMAuth';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections/index';
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,32 +83,21 @@ 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">
{collectionAuth?.mode === 'oauth2' ? (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-1">
<div>Collection level auth is: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(collectionAuth?.mode)}</div>
</div>
<div className="text-sm opacity-50">
Note: You need to use scripting to set the access token in the request headers.
</div>
</div>
) : (
<>
<div>Auth inherited from the Collection: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(collectionAuth?.mode)}</div>
</>
)}
</div>
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
}
}
};
return (
<StyledWrapper className="w-full mt-1">
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>

View File

@@ -152,7 +152,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
{(script.req || script.res) && (
item.preScriptResponseErrorMessage || item.postResponseScriptErrorMessage ?
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ?
<ErrorIndicator /> :
<ContentIndicator />
)}

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: 0.8125rem;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { clearRequestTimeline } from 'providers/ReduxStore/slices/collections/index';
const ClearTimeline = ({ collection, item }) => {
const dispatch = useDispatch();
const clearResponse = () =>
dispatch(
clearRequestTimeline({
itemUid: item.uid,
collectionUid: collection.uid
})
);
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={clearResponse} className='text-link hover:underline' title="Clear Timeline">
Clear Timeline
</button>
</StyledWrapper>
);
};
export default ClearTimeline;

View File

@@ -10,10 +10,10 @@ import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper';
import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
import { getEncoding, prettifyJson, uuid } from 'utils/common/index';
import { getEncoding, uuid } from 'utils/common/index';
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
if (data === undefined || !dataBuffer) {
if (data === undefined || !dataBuffer || !mode) {
return '';
}
@@ -37,15 +37,12 @@ const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
if (filter) {
try {
data = JSONPath({ path: filter, json: data });
return prettifyJson(JSON.stringify(data));
} catch (e) {
console.warn('Could not apply JSONPath filter:', e.message);
}
}
// Prettify the JSON string directly instead of parse->stringify to avoid
// issues like rounding numbers bigger than Number.MAX_SAFE_INTEGER etc.
return prettifyJson(rawData);
return safeStringifyJSON(data, true);
}
if (mode.includes('xml')) {
@@ -60,7 +57,7 @@ const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
return data;
}
return prettifyJson(rawData);
return safeStringifyJSON(data, true);
};
const formatErrorMessage = (error) => {
@@ -68,7 +65,7 @@ const formatErrorMessage = (error) => {
const remoteMethodError = "Error invoking remote method 'send-http-request':";
if (error.includes(remoteMethodError)) {
if (error?.includes(remoteMethodError)) {
const parts = error.split(remoteMethodError);
return parts[1]?.trim() || error;
}
@@ -80,7 +77,11 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const formattedData = formatResponse(data, dataBuffer, getEncoding(headers), mode, filter);
const responseEncoding = getEncoding(headers);
const formattedData = useMemo(
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
[data, dataBuffer, responseEncoding, mode, filter]
);
const { displayedTheme } = useTheme();
const debouncedResultFilterOnChange = debounce((e) => {
@@ -91,7 +92,9 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
// Always show raw
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
if (mode.includes('html') && typeof data === 'string') {
if (!mode || !contentType) return allowedPreviewModes;
if (mode?.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) {
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
@@ -139,7 +142,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
return (
<StyledWrapper
className="w-full h-full relative"
className="w-full h-full relative flex"
style={{ maxWidth: width }}
queryFilterEnabled={queryFilterEnabled}
>

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,109 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.timeline-event {
padding: 8px 0 0 0;
cursor: pointer;
}
.timeline-event-content {
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;
}
.oauth-request-item-content {
border-radius: 4px;
margin-top: 0.5rem;
}
.collapsible-section {
margin-bottom: 12px;
.section-header {
cursor: pointer;
&: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 +117,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

@@ -0,0 +1,37 @@
import QueryResult from "components/ResponsePane/QueryResult/index";
import { useState } from "react";
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }) => {
const [isBodyCollapsed, toggleBody] = useState(true);
return (
<div className="collapsible-section">
<div className="section-header" onClick={() => toggleBody(!isBodyCollapsed)}>
<pre className="flex flex-row items-center text-indigo-500/80 dark:text-indigo-500/80">
<div className="opacity-70">{isBodyCollapsed ? '▼' : '▶'}</div> Body
</pre>
</div>
{isBodyCollapsed && (
<div className="mt-2">
{data || dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResult
item={item}
collection={collection}
width={width}
data={data}
dataBuffer={dataBuffer}
headers={headers}
error={error}
key={item?.uid}
/>
</div>
) : (
<div className="text-gray-500">No Body found</div>
)}
</div>
)}
</div>
)
}
export default BodyBlock;

View File

@@ -0,0 +1,54 @@
import { useState } from "react";
const HeadersBlock = ({ headers, type }) => {
const [areHeadersCollapsed, toggleHeaders] = useState(true);
return (
<div className="collapsible-section mt-2">
<div className="section-header" onClick={() => toggleHeaders(!areHeadersCollapsed)}>
<pre className="flex flex-row items-center text-indigo-500/80 dark:text-indigo-500/80">
<div className="opacity-70">{areHeadersCollapsed ? '▼' : '▶'}</div> Headers
{headers && Object.keys(headers).length > 0 &&
<div className="ml-1">({Object.keys(headers).length})</div>
}
</pre>
</div>
{areHeadersCollapsed && (
<div className="mt-1">
{headers && Object.keys(headers).length > 0
? <Headers headers={headers} type={type} />
: <div className="text-gray-500">No Headers found</div>
}
</div>
)}
</div>
)
};
const Headers = ({ headers, type }) => {
if (Array.isArray(headers)) {
return (
<div className="mt-1 text-sm">
{headers.map((header, index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{header?.name}:</span>
<span className="whitespace-pre-wrap">{String(header?.value)}</span>
</pre>
))}
</div>
);
} else {
return (
<div className="mt-1 text-sm">
{Object.entries(headers).map(([key, value], index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{key}:</span>
<span>{String(value)}</span>
</pre>
))}
</div>
);
}
};
export default HeadersBlock;

View File

@@ -0,0 +1,19 @@
const Method = ({ method }) => {
return (
<span className={`${methodColors[method?.toUpperCase()] || 'text-white'} font-bold`}>
{method?.toUpperCase()}
</span>
)
}
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',
};
export default Method;

View File

@@ -0,0 +1,26 @@
const Status = ({ statusCode, statusText }) => {
return (
<span
className={`${
statusColor(statusCode) || 'text-white'
} font-bold`}
>
{statusCode}{' '}
{statusText || ''}
</span>
)
}
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';
}
};
export default Status;

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from "react";
const getRelativeTime = (date) => {
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const diff = (date - new Date()) / 1000;
const timeUnits = [
{ unit: 'year', seconds: 31536000 },
{ unit: 'month', seconds: 2592000 },
{ unit: 'week', seconds: 604800 },
{ unit: 'day', seconds: 86400 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
{ unit: 'second', seconds: 1 }
];
for (const { unit, seconds } of timeUnits) {
if (Math.abs(diff) >= seconds || unit === 'second') {
return rtf.format(Math.round(diff / seconds), unit);
}
}
};
export const RelativeTime = ({ timestamp }) => {
const [relativeTime, setRelativeTime] = useState(getRelativeTime(new Date(timestamp)));
useEffect(() => {
const interval = setInterval(() => {
setRelativeTime(getRelativeTime(new Date(timestamp)));
}, 1000);
return () => clearInterval(interval);
}, [timestamp]);
return <pre className="text-xs">{relativeTime}</pre>;
};

View File

@@ -0,0 +1,46 @@
const Network = ({ logs }) => {
return (
<div className="bg-black/5 text-white network-logs rounded overflow-auto h-96">
<pre className="whitespace-pre-wrap">
{logs.map((entry, index) => (
<NetworkLogsEntry key={index} entry={entry} />
))}
</pre>
</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>
);
};
export default Network;

View File

@@ -0,0 +1,41 @@
import Headers from "../Common/Headers/index";
import BodyBlock from "../Common/Body/index";
const safeStringifyJSONIfNotString = (obj) => {
if (obj === null || obj === undefined) return '';
if (typeof obj === 'string') {
return obj;
}
try {
return JSON.stringify(obj);
} catch (e) {
return '[Unserializable Object]';
}
};
const Request = ({ collection, request, item, width }) => {
let { url, headers, data, dataBuffer, error } = request || {};
if (!dataBuffer) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
}
return (
<div>
{/* Method and URL */}
<div className="mb-1 flex gap-2">
<pre className="whitespace-pre-wrap">{url}</pre>
</div>
{/* Headers */}
<Headers headers={headers} type={'request'} />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
</div>
)
}
export default Request;

View File

@@ -0,0 +1,43 @@
import BodyBlock from "../Common/Body/index";
import Headers from "../Common/Headers/index";
import Status from "../Common/Status/index";
const safeStringifyJSONIfNotString = (obj) => {
if (obj === null || obj === undefined) return '';
if (typeof obj === 'string') {
return obj;
}
try {
return JSON.stringify(obj);
} catch (e) {
return '[Unserializable Object]';
}
};
const Response = ({ collection, response, item, width }) => {
let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
if (!dataBuffer) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
}
return (
<div>
{/* Status */}
<div className="mb-1">
<Status statusCode={status || statusCode} statusText={statusText} />
{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 */}
<Headers headers={headers} type={'response'} />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
</div>
)
}
export default Response;

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import Network from "./Network/index";
import Request from "./Request/index";
import Response from "./Response/index";
import Method from "./Common/Method/index";
import Status from "./Common/Status/index";
import { RelativeTime } from "./Common/Time/index";
const TimelineItem = ({ timestamp, request, response, item, collection, width, isOauth2 }) => {
const [isCollapsed, _toggleCollapse] = useState(false);
const [activeTab, setActiveTab] = useState('request');
const toggleCollapse = () => _toggleCollapse(prev => !prev);
const { method, status, statusCode, statusText, url = '' } = request || {};
const { status: responseStatus, statusCode: responseStatusCode, statusText: responseStatusText } = response || {};
const showNetworkLogs = response.timeline && response.timeline.length > 0;
return (
<div className={`border-b-2 ${isOauth2 ? 'border-indigo-700/50' : 'border-amber-700/50' } py-2`}>
<div className="oauth-request-item-header cursor-pointer" onClick={toggleCollapse}>
<div className="flex justify-between items-center min-w-0">
<div className="flex items-center space-x-2 min-w-0">
<Status statusCode={responseStatus || responseStatusCode} statusText={responseStatusText} />
<Method method={method} />
<Status statusCode={status || statusCode} statusText={statusText} />
{isOauth2 ? <pre className="opacity-50">[oauth2.0]</pre> : null}
<pre className="opacity-70">[{new Date(timestamp).toISOString()}]</pre>
</div>
<span className="text-sm text-gray-400 flex-shrink-0 overflow-hidden text-ellipsis whitespace-nowrap">
<RelativeTime timestamp={timestamp} />
</span>
</div>
<div className="truncate text-sm mt-1">{url}</div>
</div>
{isCollapsed && (<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' && (
<Request request={request} item={item} collection={collection} width={width} />
)}
{/* Response Tab */}
{activeTab === 'response' && (
<Response response={response} item={item} collection={collection} width={width} />
)}
{/* Network Logs Tab */}
{activeTab === 'networkLogs' && showNetworkLogs && (
<Network logs={response?.timeline} />
)}
</div>
</div>)}
</div>
);
};
export default TimelineItem;

View File

@@ -1,61 +1,123 @@
import React from 'react';
import forOwn from 'lodash/forOwn';
import { safeStringifyJSON } from 'utils/common';
import React, { useState } from 'react';
import StyledWrapper from './StyledWrapper';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
import { get } from 'lodash';
import TimelineItem from './TimelineItem/index';
const Timeline = ({ request, response }) => {
const requestHeaders = [];
const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
if (authMode !== 'inherit') return null;
request = request || {};
response = response || {};
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
uid: collection.uid,
auth: collectionAuth
};
forOwn(request.headers, (value, key) => {
requestHeaders.push({
name: key,
value
});
});
// Get path from collection to item
let path = [];
let currentItem = findItemInCollection(collection, item?.uid);
while (currentItem) {
path.unshift(currentItem);
currentItem = findParentItemInCollection(collection, currentItem?.uid);
}
let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
// 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);
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 flex flex-grow flex-col"
style={{ maxWidth: width - 60, overflowWrap: 'break-word' }}
>
{combinedTimeline.map((event, index) => {
if (event.type === 'request') {
const { data, timestamp } = event;
const { request, response } = data;
return (
<pre className="line request" key={h.name}>
<span className="arrow">{'>'}</span> {h.name}: {h.value}
</pre>
<div key={index} className="timeline-event mb-2">
<TimelineItem
timestamp={timestamp}
request={request}
response={response}
item={item}
collection={collection}
width={width}
/>
</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, timestamp } = event;
const { debugInfo } = data;
return (
<pre className="line response" key={h[0]}>
<span className="arrow">{'<'}</span> {h[0]}: {h[1]}
</pre>
<div key={index} className="timeline-event">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div className='ml-4'>
<TimelineItem
key={idx}
timestamp={timestamp}
request={data?.request}
response={data?.response}
item={item}
collection={collection}
width={width - 50}
isOauth2={true}
/>
</div>
))
) : (
<div>No debug information available.</div>
)}
</div>
</div>
);
})}
</div>
}
return null;
})}
</StyledWrapper>
);
};
export default Timeline;
export default Timeline;

View File

@@ -18,6 +18,7 @@ import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ClearTimeline from './ClearTimeline/index';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const dispatch = useDispatch();
@@ -26,6 +27,10 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
const requestTimeline = ([...(collection.timeline || [])]).filter(obj => {
if (obj.itemUid === item.uid) return true;
});
useEffect(() => {
if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage) {
setShowScriptErrorCard(true);
@@ -63,7 +68,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} />;
@@ -83,7 +88,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
);
}
if (!item.response) {
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<Placeholder />
@@ -134,11 +139,17 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
onClick={() => setShowScriptErrorCard(true)}
/>
)}
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<ResponseSize size={response.size} />
{focusedTab?.responsePaneTab === "timeline" ? (
<ClearTimeline item={item} collection={collection} />
) : item?.response ? (
<>
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<ResponseSize size={response.size} />
</>
) : null}
</div>
) : null}
</div>
@@ -152,7 +163,17 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
onClose={() => setShowScriptErrorCard(false)}
/>
)}
{getTabPanel(focusedTab.responsePaneTab)}
{!item?.response ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}
width={rightPaneWidth}
/>
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</section>
</StyledWrapper>
);

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

@@ -98,7 +98,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
const handleClick = (event) => {
if (event.detail != 1) return;
if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);

View File

@@ -24,6 +24,7 @@ import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl } from 'providers/ReduxStore/slices/collections/index';
const useIpcEvents = () => {
const dispatch = useDispatch();
@@ -160,7 +161,17 @@ const useIpcEvents = () => {
const removeSnapshotHydrationListener = ipcRenderer.on('main:hydrate-app-with-ui-state-snapshot', (val) => {
dispatch(hydrateCollectionWithUiStateSnapshot(val));
})
});
const removeCollectionOauth2CredentialsUpdatesListener = ipcRenderer.on('main:credentials-update', (val) => {
const payload = {
...val,
itemUid: val.itemUid || null,
folderUid: val.folderUid || null,
credentialsId: val.credentialsId || 'credentials'
};
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
return () => {
removeCollectionTreeUpdateListener();
@@ -181,6 +192,7 @@ const useIpcEvents = () => {
removeSystemProxyEnvUpdatesListener();
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
};
}, [isElectron]);
};

View File

@@ -37,7 +37,9 @@ import {
resetRunResults,
responseReceived,
updateLastAction,
setCollectionSecurityConfig
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
} from './index';
import { each } from 'lodash';
@@ -48,6 +50,7 @@ import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'uti
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState();
@@ -237,11 +240,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
})
);
})
@@ -1230,33 +1242,97 @@ export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (
export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getState) => {
const collectionSnapshotData = payload;
return new Promise((resolve, reject) => {
const state = getState();
try {
if(!collectionSnapshotData) resolve();
const { pathname, selectedEnvironment } = collectionSnapshotData;
const collection = findCollectionByPathname(state.collections.collections, pathname);
const collectionCopy = cloneDeep(collection);
const collectionUid = collectionCopy?.uid;
const collectionSnapshotData = payload;
return new Promise((resolve, reject) => {
const state = getState();
try {
if(!collectionSnapshotData) resolve();
const { pathname, selectedEnvironment } = collectionSnapshotData;
const collection = findCollectionByPathname(state.collections.collections, pathname);
const collectionCopy = cloneDeep(collection);
const collectionUid = collectionCopy?.uid;
// update selected environment
if (selectedEnvironment) {
const environment = findEnvironmentInCollectionByName(collectionCopy, selectedEnvironment);
if (environment) {
dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
}
// update selected environment
if (selectedEnvironment) {
const environment = findEnvironmentInCollectionByName(collectionCopy, selectedEnvironment);
if (environment) {
dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
}
}
// todo: add any other redux state that you want to save
// todo: add any other redux state that you want to save
resolve();
}
catch(error) {
reject(error);
}
});
};
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: safeParseJSON(safeStringifyJSON(debugInfo)),
folderUid: folderUid || null,
itemUid: !folderUid ? itemUid : null
})
);
resolve(credentials);
})
.catch(reject);
});
};
export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => {
const { request, collection, folderUid, itemUid } = payload;
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:refresh-oauth2-credentials', { request, collection })
.then(({ credentials, url, collectionUid, debugInfo, credentialsId }) => {
dispatch(
collectionAddOauth2CredentialsByUrl({
credentials,
url,
collectionUid,
credentialsId,
debugInfo: safeParseJSON(safeStringifyJSON(debugInfo)),
folderUid: folderUid || null,
itemUid: !folderUid ? itemUid : 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(() => {
dispatch(
collectionClearOauth2CredentialsByUrl({
url,
collectionUid,
})
);
resolve();
}
catch(error) {
reject(error);
}
});
};
})
.catch(reject);
});
};
export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {

View File

@@ -281,13 +281,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,
}
});
}
}
},
@@ -301,6 +324,23 @@ export const collectionsSlice = createSlice({
}
}
},
clearTimeline: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
collection.timeline = [];
}
},
clearRequestTimeline: (state, action) => {
const { collectionUid, itemUid } = action.payload || {};
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
if (itemUid) {
collection.timeline = collection?.timeline?.filter(t => t?.itemUid !== itemUid);
}
}
},
saveRequest: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1539,6 +1579,23 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.tests', action.payload.tests);
}
},
updateFolderAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (!collection) return;
const folder = collection ? findItemInCollection(collection, action.payload.itemUid) : null;
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);
@@ -2019,7 +2076,97 @@ export const collectionsSlice = createSlice({
set(folder, 'root.docs', action.payload.docs);
}
}
}
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
// Update oauth2Credentials (latest token)
if (!collection.oauth2Credentials) {
collection.oauth2Credentials = [];
}
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) {
let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
const filteredOauth2Credentials = filter(
collectionOauth2Credentials,
(creds) =>
!(creds.url === url && creds.collectionUid === collectionUid)
);
collection.oauth2Credentials = filteredOauth2Credentials;
}
},
collectionGetOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, url, credentialsId } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
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);
}
},
}
});
@@ -2046,6 +2193,8 @@ export const {
requestCancelled,
responseReceived,
responseCleared,
clearTimeline,
clearRequestTimeline,
saveRequest,
deleteRequestDraft,
newEphemeralHttpRequest,
@@ -2124,6 +2273,11 @@ export const {
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
moveCollection
} = collectionsSlice.actions;

View File

@@ -28,12 +28,16 @@ if (!SERVER_RENDERED) {
undef: true,
browser: true,
devel: true,
module: true,
node: true,
predef: {
'bru': false,
'req': false,
'res': false,
'test': false,
'expect': false
'expect': false,
'require': false,
'module': false
}
};
@@ -51,15 +55,19 @@ if (!SERVER_RENDERED) {
* Filter out errors due to top level awaits
* See https://github.com/usebruno/bruno/issues/1214
*
* - E058: Missing semicolon at top level await
* codemirror error: "Missing semicolon."
* - W024: 'await' used as identifier (JSHint doesn't recognize top-level await syntax)
* codemirror error: "Expected an identifier and instead saw 'await' (a reserved word)."
*
* Once JSHINT top level await support is added, this file can be removed
* and we can use the default javascript-lint addon from codemirror
*/
errors = filter(errors, (error) => {
if (error.code === 'E058') {
if (error.code === 'E058' || error.code === 'W024') {
if (
error.evidence &&
error.evidence.includes('await') &&
error.reason === 'Missing semicolon.' &&
error.scope === '(main)'
) {
return false;

View File

@@ -1,6 +1,8 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import path from 'utils/common/path';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@@ -381,11 +383,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
di.request.auth.oauth2 = {
grantType: grantType,
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
refreshTokenUrl: get(si.request, 'auth.oauth2.refreshTokenUrl', ''),
username: get(si.request, 'auth.oauth2.username', ''),
password: get(si.request, 'auth.oauth2.password', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', '')
scope: get(si.request, 'auth.oauth2.scope', ''),
credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
case 'authorization_code':
@@ -394,19 +404,35 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
callbackUrl: get(si.request, 'auth.oauth2.callbackUrl', ''),
authorizationUrl: get(si.request, 'auth.oauth2.authorizationUrl', ''),
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
refreshTokenUrl: get(si.request, 'auth.oauth2.refreshTokenUrl', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', ''),
pkce: get(si.request, 'auth.oauth2.pkce', false)
credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
pkce: get(si.request, 'auth.oauth2.pkce', false),
credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
case 'client_credentials':
di.request.auth.oauth2 = {
grantType: grantType,
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
refreshTokenUrl: get(si.request, 'auth.oauth2.refreshTokenUrl', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', '')
scope: get(si.request, 'auth.oauth2.scope', ''),
credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
}
@@ -953,12 +979,15 @@ export const getAllVariables = (collection, item) => {
const uniqueMaskedVariables = [...new Set([...filteredMaskedEnvVariables, ...filteredMaskedGlobalEnvVariables])];
const oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials })
return {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
pathParams: {
...pathParams
@@ -1025,4 +1054,37 @@ const mergeVars = (collection, requestTreePath = []) => {
folderVariables,
requestVariables
};
};
};
export const getEnvVars = (environment = {}) => {
const variables = environment.variables;
if (!variables || !variables.length) {
return {
__name__: environment.name
};
}
const envVars = {};
each(variables, (variable) => {
if (variable.enabled) {
envVars[variable.name] = variable.value;
}
});
return {
...envVars,
__name__: environment.name
};
};
export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => {
let credentialsVariables = {};
oauth2Credentials.forEach(({ credentialsId, credentials }) => {
if (credentials) {
Object.entries(credentials).forEach(([key, value]) => {
credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value;
});
}
});
return credentialsVariables;
};

View File

@@ -1,6 +1,5 @@
import { customAlphabet } from 'nanoid';
import xmlFormat from 'xml-formatter';
import { format as jsoncFormat, applyEdits as jsoncApplyEdits } from 'jsonc-parser';
// a customized version of nanoid without using _ and -
export const uuid = () => {
@@ -27,13 +26,6 @@ export const waitForNextTick = () => {
});
};
export const prettifyJson = (doc) => {
return jsoncApplyEdits(
doc,
jsoncFormat(doc, null, {insertSpaces: true, tabSize: 2})
);
}
export const safeParseJSON = (str) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
@@ -189,4 +181,4 @@ export const getEncoding = (headers) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
return charsetMatch?.[1];
}
}

View File

@@ -431,6 +431,66 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: "header" //By default we are placing the apikey values in headers!
}
} else if (auth.type === 'oauth2'){
const findValueUsingKey = (key) => {
return auth?.oauth2?.find(v => v?.key == key)?.value || ''
}
const oauth2GrantTypeMaps = {
'authorization_code_with_pkce': 'authorization_code',
'authorization_code': 'authorization_code',
'client_credentials': 'client_credentials',
'password_credentials': 'password_credentials'
}
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
if (grantType) {
brunoRequestItem.request.auth.mode = 'oauth2';
switch(grantType) {
case 'authorization_code':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'authorization_code',
authorizationUrl: findValueUsingKey('authUrl'),
callbackUrl: findValueUsingKey('redirect_uri'),
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'password_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'password',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
username: findValueUsingKey('username'),
password: findValueUsingKey('password'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'client_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'client_credentials',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
}
}
}
}

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));
@@ -36,17 +37,7 @@ const sendHttpRequest = async (item, collection, environment, runtimeVariables)
export const sendCollectionOauth2Request = async (collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('send-collection-oauth2-request', collection, environment, runtimeVariables)
.then(resolve)
.catch(reject);
});
};
export const clearOauth2Cache = async (uid) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('clear-oauth2-cache', uid).then(resolve).catch(reject);
resolve({});
});
};

View File

@@ -147,13 +147,13 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
if (request.auth) {
const username = _interpolate(request.auth.username) || '';
const password = _interpolate(request.auth.password) || '';
if (request.basicAuth) {
const username = _interpolate(request.basicAuth.username) || '';
const password = _interpolate(request.basicAuth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
delete request.auth;
delete request.basicAuth;
}
if (request.awsv4config) {
@@ -165,12 +165,14 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.awsv4config.profileName = _interpolate(request.awsv4config.profileName) || '';
}
// interpolate vars for ntlmConfig auth
if (request.ntlmConfig) {
request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';
request.ntlmConfig.password = _interpolate(request.ntlmConfig.password) || '';
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
// interpolate vars for ntlmConfig auth
if (request.ntlmConfig) {
request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';
request.ntlmConfig.password = _interpolate(request.ntlmConfig.password) || '';
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
if(request?.auth) delete request.auth;
if (request) return request;
};

View File

@@ -38,7 +38,7 @@ const prepareRequest = (item = {}, collection = {}) => {
const collectionAuth = get(collection, 'root.request.auth');
if (collectionAuth && request.auth?.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.auth = {
axiosRequest.basicAuth = {
username: get(collectionAuth, 'basic.username'),
password: get(collectionAuth, 'basic.password')
};
@@ -69,7 +69,7 @@ const prepareRequest = (item = {}, collection = {}) => {
if (request.auth && request.auth.mode !== 'inherit') {
if (request.auth.mode === 'basic') {
axiosRequest.auth = {
axiosRequest.basicAuth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};

View File

@@ -117,7 +117,7 @@ describe('prepare-request: prepareRequest', () => {
const result = prepareRequest(item, collection);
const expected = { username: 'testUser', password: 'testPass123' };
expect(result.auth).toEqual(expected);
expect(result.basicAuth).toEqual(expected);
});
});

View File

@@ -1,5 +1,5 @@
{
"version": "v1.38.1",
"version": "2.0.0",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",

View File

@@ -54,6 +54,7 @@ const jsonToCollectionBru = async (json, isFolder) => {
res: _.get(json, 'request.vars.res', [])
},
tests: _.get(json, 'request.tests', ''),
auth: _.get(json, 'request.auth', {}),
docs: _.get(json, 'docs', '')
};
@@ -66,10 +67,6 @@ const jsonToCollectionBru = async (json, isFolder) => {
};
}
if (!isFolder) {
collectionBruJson.auth = _.get(json, 'request.auth', {});
}
return _jsonToCollectionBru(collectionBruJson);
} catch (error) {
return Promise.reject(error);

View File

@@ -24,6 +24,7 @@ const Watcher = require('./app/watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const lastOpenedCollections = new LastOpenedCollections();
@@ -160,6 +161,16 @@ app.on('ready', async () => {
return { action: 'deny' };
});
mainWindow.webContents.on('did-finish-load', () => {
let ogSend = mainWindow.webContents.send;
mainWindow.webContents.send = function(channel, ...args) {
return ogSend.apply(this, [channel, ...args?.map(_ => {
// todo: replace this with @msgpack/msgpack encode/decode
return safeParseJSON(safeStringifyJSON(_));
})]);
}
});
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow);

View File

@@ -31,6 +31,11 @@ const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modif
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const interpolateVars = require('./network/interpolate-vars');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token } = require('../utils/oauth2');
const { getCertsAndProxyConfig } = require('./network');
const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const environmentSecretsStore = new EnvironmentSecretsStore();
@@ -896,6 +901,52 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:fetch-oauth2-credentials', async (event, { itemUid, request, collection }) => {
try {
if (request.oauth2) {
let requestCopy = _.cloneDeep(request);
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);
const partialItem = { uid: itemUid };
const requestTreePath = getTreePathFromCollectionToItem(collection, partialItem);
if (requestTreePath && requestTreePath.length > 0) {
mergeVars(collection, requestCopy, requestTreePath);
}
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
request: requestCopy,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
});
const { oauth2: { grantType }} = requestCopy || {};
let credentials, url, credentialsId;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, forceFetch: true, certsAndProxyConfig }));
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, forceFetch: true, certsAndProxyConfig }));
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, forceFetch: true, certsAndProxyConfig }));
break;
}
return { credentials, url, collectionUid, credentialsId, debugInfo };
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:load-request-via-worker', async (event, { collectionUid, pathname }) => {
let fileStats;
try {
@@ -945,6 +996,31 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:refresh-oauth2-credentials', async (event, { request, collection }) => {
try {
if (request.oauth2) {
let requestCopy = _.cloneDeep(request);
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 certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
request: requestCopy,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
});
let { credentials, url, credentialsId, debugInfo } = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
return { credentials, url, collectionUid, credentialsId, debugInfo };
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => {
let fileStats;
try {

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,104 @@ 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());
});
const { session: webSession } = window.webContents;
// Intercept request events and gather data
webSession.webRequest.onBeforeRequest((details, callback) => {
const { id: requestId, url, method, resourceType, frameId } = details;
if (resourceType === 'mainFrame') {
// This is a main frame request
currentMainRequest = {
requestId,
resourceType,
frameId,
request: {
url,
method,
headers: {},
error: null
},
response: {
headers: {},
status: null,
statusText: null,
error: null
},
fromCache: false,
completed: true,
requests: [], // No sub-requests in this context
};
// Add to mainRequests
// pushing the currentMainRequest to debugInfo
// the currentMainRequest will be further updated by object reference
debugInfo.data.push(currentMainRequest);
}
callback({ cancel: false });
});
webSession.webRequest.onBeforeSendHeaders((details, callback) => {
const { id: requestId, requestHeaders, method, url } = details;
if (currentMainRequest?.requestId === requestId) {
currentMainRequest.request = {
url,
headers: requestHeaders,
method
};
}
callback({ cancel: false, requestHeaders });
});
webSession.webRequest.onHeadersReceived((details, callback) => {
const { id: requestId, url, statusCode, responseHeaders, method } = details;
if (currentMainRequest?.requestId === requestId) {
currentMainRequest.response = {
url,
method,
status: statusCode,
headers: responseHeaders
};
}
callback({ cancel: false, responseHeaders });
});
webSession.webRequest.onCompleted((details) => {
const { id: requestId, fromCache } = details;
if (currentMainRequest?.requestId === requestId) {
currentMainRequest.completed = true;
currentMainRequest.fromCache = fromCache;
}
});
webSession.webRequest.onErrorOccurred((details) => {
const { id: requestId, error } = details;
if (currentMainRequest?.requestId === requestId) {
currentMainRequest.response.error = error;
}
});
function onWindowRedirect(url) {
// check if the redirect is to the callback URL and if it contains an authorization code
// 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))) {
if (!new URL(url).searchParams.has('code')) {
reject(new Error('Invalid Callback URL: Does not contain an authorization code'));
}
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 +139,36 @@ 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 +177,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 +191,4 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => {
});
};
module.exports = { authorizeUserInWindow, matchesCallbackUrl };
module.exports = { authorizeUserInWindow, matchesCallbackUrl };

View File

@@ -3,11 +3,32 @@ 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('../../utils/proxy-util');
const { addCookieToJar, getCookieStringForUrl } = require('../../utils/cookies');
const { preferencesUtil } = require('../../store/preferences');
const { safeStringifyJSON } = require('../../utils/common');
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 +70,16 @@ const checkConnection = (host, port) =>
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance() {
function makeAxiosInstance({
proxyMode = 'off',
proxyConfig = {},
requestMaxRedirects = 5,
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) {
@@ -68,6 +92,7 @@ function makeAxiosInstance() {
return data;
},
proxy: false,
maxRedirects: 0,
headers: {
"User-Agent": `bruno-runtime/${version}`
}
@@ -75,6 +100,45 @@ function makeAxiosInstance() {
instance.interceptors.request.use(async (config) => {
const url = URL.parse(config.url);
config.metadata = config.metadata || {};
config.metadata.startTime = new Date().getTime();
const timeline = config.metadata.timeline || []
// Add initial request details to the timeline
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Preparing request to ${config.url}`,
});
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Current time is ${new Date().toISOString()}`,
});
// Add request method and headers
timeline.push({
timestamp: new Date(),
type: 'request',
message: `${config.method.toUpperCase()} ${config.url}`,
});
Object.entries(config.headers).forEach(([key, value]) => {
timeline.push({
timestamp: new Date(),
type: 'requestHeader',
message: `${key}: ${value}`,
});
});
// Add request data if available
if (config.data) {
let requestData = typeof config.data === 'string' ? config.data : JSON.stringify(config.data, null, 2);
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,21 +155,244 @@ function makeAxiosInstance() {
}
config.headers['request-start-time'] = Date.now();
const agentOptions = {
...httpsAgentRequestFields,
keepAlive: true,
};
try {
// 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,
});
}
catch(err) {
timeline.push({
timestamp: new Date(),
type: 'error',
message: err?.message,
});
}
config.metadata.timeline = timeline;
return config;
});
let redirectCount = 0
instance.interceptors.response.use(
(response) => {
let timeline;
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
redirectCount = 0;
const config = response.config;
timeline = config?.metadata?.timeline || []
const duration = end - config?.metadata.startTime;
const httpVersion = response?.request?.res?.httpVersion || response?.httpVersion;
if (httpVersion?.startsWith('2')) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using HTTP/2, server supports multiplexing`,
});
}
timeline.push({
timestamp: new Date(),
type: 'response',
message: `HTTP/${httpVersion || '1.1'} ${response.status} ${response.statusText}`,
});
Object.entries(response.headers).forEach(([key, value]) => {
timeline.push({
timestamp: new Date(),
type: 'responseHeader',
message: `${key}: ${value}`,
});
});
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Request completed in ${duration} ms`,
});
response.timeline = timeline;
return response;
},
(error) => {
const config = error.config;
const timeline = config?.metadata?.timeline || [];
timeline?.push({
timestamp: new Date(),
type: 'error',
message: 'there was an error executing the request!'
});
if (error.response) {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
const duration = end - config?.metadata?.startTime;
if (error.response && redirectResponseCodes.includes(error.response.status)) {
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]) => {
timeline.push({
timestamp: new Date(),
type: 'responseHeader',
message: `${key}: ${value}`,
});
});
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Request completed in ${duration} ms`,
});
// Attach the timeline to the response
error.response.timeline = timeline;
if (redirectCount >= requestMaxRedirects) {
const errorResponseData = error.response.data;
const dataBuffer = Buffer.isBuffer(errorResponseData) ? errorResponseData : Buffer.from(errorResponseData);
timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(errorResponseData?.toString?.())
});
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: errorResponseData?.toString?.(),
size: Buffer.byteLength(dataBuffer),
duration: error.response.headers.get('request-duration') ?? 0,
timeline: error.response.timeline
};
}
// Increase redirect count
redirectCount++;
const locationHeader = error.response.headers.location;
let redirectUrl = locationHeader;
// Handle relative URLs by resolving them against the original request URL
if (locationHeader && !locationHeader.match(/^https?:\/\//i)) {
// It's a relative URL, resolve it against the original URL
redirectUrl = URL.resolve(error.config.url, locationHeader);
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Resolving relative redirect URL: ${locationHeader}${redirectUrl}`,
});
}
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(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
requestConfig.headers['cookie'] = cookieString;
}
}
setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
httpsAgentRequestFields,
interpolationOptions,
timeline
});
requestConfig.metadata.timeline = timeline;
// Make the redirected request
return instance(requestConfig);
}
else {
const errorResponseData = error.response.data;
const dataBuffer = Buffer.isBuffer(errorResponseData) ? errorResponseData : Buffer.from(errorResponseData);
Object.entries(error?.response?.headers || {}).forEach(([key, value]) => {
timeline.push({
timestamp: new Date(),
type: 'responseHeader',
message: `${key}: ${value}`,
});
});
timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(errorResponseData?.toString?.())
});
error?.cause && timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(error?.cause)
});
error?.errors && timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(error?.errors)
});
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: errorResponseData?.toString?.(),
size: Buffer.byteLength(dataBuffer),
duration: error.response.headers.get('request-duration') ?? 0,
timeline
};
}
}
else if (error?.code) {
Object.entries(error?.response?.headers || {}).forEach(([key, value]) => {
timeline.push({
timestamp: new Date(),
type: 'responseHeader',
message: `${key}: ${value}`,
});
});
timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(error?.cause)
});
timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(error?.errors)
});
return {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: 'request failed, check timeline network logs',
timeline
};
}
return Promise.reject(error);
}

View File

@@ -1,76 +0,0 @@
const { each, filter } = require('lodash');
const sortCollection = (collection) => {
const items = collection.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
collection.items = folderItems.concat(requestItems);
each(folderItems, (item) => {
sortCollection(item);
});
};
const sortFolder = (folder = {}) => {
const items = folder.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
folder.items = folderItems.concat(requestItems);
each(folderItems, (item) => {
sortFolder(item);
});
return folder;
};
const findItemInCollection = (collection, itemId) => {
let item = null;
if (collection.uid === itemId) {
return collection;
}
if (collection.items && collection.items.length) {
collection.items.forEach((item) => {
if (item.uid === itemId) {
item = item;
} else if (item.type === 'folder') {
item = findItemInCollection(item, itemId);
}
});
}
return item;
};
const getAllRequestsInFolderRecursively = (folder = {}) => {
let requests = [];
if (folder.items && folder.items.length) {
folder.items.forEach((item) => {
if (item.type !== 'folder') {
requests.push(item);
} else {
requests = requests.concat(getAllRequestsInFolderRecursively(item));
}
});
}
return requests;
};
module.exports = {
sortCollection,
sortFolder,
findItemInCollection,
getAllRequestsInFolderRecursively
};

View File

@@ -1,90 +1,35 @@
const os = require('os');
const fs = require('fs');
const qs = require('qs');
const https = require('https');
const tls = require('tls');
const axios = require('axios');
const path = require('path');
const decomment = require('decomment');
const fs = require('fs');
const tls = require('tls');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
const FormData = require('form-data');
const { ipcMain } = require('electron');
const { isUndefined, isNull, each, get, compact, cloneDeep, forOwn, extend } = require('lodash');
const { each, get, extend, cloneDeep } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const prepareRequest = require('./prepare-request');
const prepareCollectionRequest = require('./prepare-collection-request');
const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
const { uuid } = require('../../utils/common');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('./digestauth-helper');
const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
const { prepareRequest } = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { makeAxiosInstance } = require('./axios-instance');
const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse } = require('../../utils/common');
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies');
const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars } = require('../../utils/collection');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('./digestauth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
const { chooseFileToSave, writeFile } = require('../../utils/filesystem');
const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
const {
resolveOAuth2AuthorizationCodeAccessToken,
transformClientCredentialsRequest,
transformPasswordCredentialsRequest
} = require('./oauth2-helper');
const Oauth2Store = require('../../store/oauth2');
const iconv = require('iconv-lite');
const FormData = require('form-data');
const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname } = require('../../utils/collection');
const { NtlmClient } = require('axios-ntlm');
const safeStringifyJSON = (data) => {
try {
return JSON.stringify(data);
} catch (e) {
return data;
}
};
const safeParseJSON = (data) => {
try {
return JSON.parse(data);
} catch (e) {
return data;
}
};
const getEnvVars = (environment = {}) => {
const variables = environment.variables;
if (!variables || !variables.length) {
return {
__name__: environment.name
};
}
const envVars = {};
each(variables, (variable) => {
if (variable.enabled) {
envVars[variable.name] = variable.value;
}
});
return {
...envVars,
__name__: environment.name
};
};
const getJsSandboxRuntime = (collection) => {
const securityConfig = get(collection, 'securityConfig', {});
return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2';
};
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const saveCookies = (url, headers) => {
if (preferencesUtil.shouldStoreCookies()) {
@@ -102,18 +47,19 @@ const saveCookies = (url, headers) => {
}
}
const configureRequest = async (
const getJsSandboxRuntime = (collection) => {
const securityConfig = get(collection, 'securityConfig', {});
return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2';
};
const getCertsAndProxyConfig = async ({
collectionUid,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
) => {
if (!protocolRegex.test(request.url)) {
request.url = `http://${request.url}`;
}
}) => {
/**
* @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors
* @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+
@@ -203,115 +149,106 @@ const configureRequest = async (
proxyConfig = preferencesUtil.getGlobalProxyConfig();
proxyMode = get(proxyConfig, 'mode', 'off');
}
return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
}
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);
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
});
const configureRequest = async (
collectionUid,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
) => {
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
if (!protocolRegex.test(request.url)) {
request.url = `http://${request.url}`;
}
let axiosInstance = makeAxiosInstance();
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
});
let requestMaxRedirects = request.maxRedirects
request.maxRedirects = 0
// Set default value for requestMaxRedirects if not explicitly set
if (requestMaxRedirects === undefined) {
requestMaxRedirects = 5; // Default to 5 redirects
}
let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
let axiosInstance = makeAxiosInstance({
proxyMode,
proxyConfig,
requestMaxRedirects,
httpsAgentRequestFields,
interpolationOptions
});
if (request.ntlmConfig) {
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
delete request.ntlmConfig;
}
if (request.oauth2) {
let requestCopy = cloneDeep(request);
switch (request?.oauth2?.grantType) {
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
let credentials, credentialsId;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const { data: authorizationCodeData, url: authorizationCodeAccessTokenUrl } =
await resolveOAuth2AuthorizationCodeAccessToken(requestCopy, collectionUid);
request.method = 'POST';
request.headers['content-type'] = 'application/x-www-form-urlencoded';
request.data = authorizationCodeData;
request.url = authorizationCodeAccessTokenUrl;
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
}
catch(error) {}
}
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const { data: clientCredentialsData, url: clientCredentialsAccessTokenUrl } =
await transformClientCredentialsRequest(requestCopy);
request.method = 'POST';
request.headers['content-type'] = 'application/x-www-form-urlencoded';
request.data = clientCredentialsData;
request.url = clientCredentialsAccessTokenUrl;
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
}
catch(error) {}
}
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const { data: passwordData, url: passwordAccessTokenUrl } = await transformPasswordCredentialsRequest(
requestCopy
);
request.method = 'POST';
request.headers['content-type'] = 'application/x-www-form-urlencoded';
request.data = passwordData;
request.url = passwordAccessTokenUrl;
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
}
catch(error) {}
}
break;
}
}
@@ -380,33 +317,6 @@ const configureRequest = async (
return axiosInstance;
};
const parseDataFromResponse = (response, disableParsingResponseJson = false) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
const charsetValue = charsetMatch?.[1];
const dataBuffer = Buffer.from(response.data);
// Overwrite the original data for backwards compatibility
let data;
if (iconv.encodingExists(charsetValue)) {
data = iconv.decode(dataBuffer, charsetValue);
} else {
data = iconv.decode(dataBuffer, 'utf-8');
}
// Try to parse response to JSON, this can quietly fail
try {
// Filter out ZWNBSP character
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
data = data.replace(/^\uFEFF/, '');
if (!disableParsingResponseJson) {
data = JSON.parse(data);
}
} catch { }
return { data, dataBuffer };
};
const registerNetworkIpc = (mainWindow) => {
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -647,22 +557,38 @@ const registerNetworkIpc = (mainWindow) => {
processEnvVars,
collectionPath
);
const requestData = request.mode == 'file'? "<request body redacted>": (typeof request?.data === 'string' ? request?.data : safeStringifyJSON(request?.data));
let requestSent = {
url: request.url,
method: request.method,
headers: request.headers,
data: requestData,
timestamp: Date.now()
}
if (requestData) {
requestSent.dataBuffer = Buffer.from(requestData);
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-sent',
requestSent: {
url: request.url,
method: request.method,
headers: request.headers,
data: request.mode == 'file'? "<request body redacted>": safeParseJSON(safeStringifyJSON(request.data)) ,
timestamp: Date.now()
},
requestSent,
collectionUid,
itemUid: item.uid,
requestUid,
cancelTokenUid
});
if (request?.oauth2Credentials) {
mainWindow.webContents.send('main:credentials-update', {
credentials: request?.oauth2Credentials?.credentials,
url: request?.oauth2Credentials?.url,
collectionUid,
credentialsId: request?.oauth2Credentials?.credentialsId,
...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
debugInfo: request?.oauth2Credentials?.debugInfo,
});
}
let response, responseTime;
try {
/** @type {import('axios').AxiosResponse} */
@@ -811,7 +737,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);
@@ -828,86 +755,11 @@ const registerNetworkIpc = (mainWindow) => {
return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
});
ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, runtimeVariables) => {
try {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const requestUid = uuid();
const collectionRoot = get(collection, 'root', {});
const _request = collectionRoot?.request;
const request = prepareCollectionRequest(_request, collection, collectionPath);
request.__bruno__executionMode = 'standalone';
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
);
interpolateVars(request, envVars, collection.runtimeVariables, processEnvVars);
const axiosInstance = await configureRequest(
collection.uid,
request,
envVars,
collection.runtimeVariables,
processEnvVars,
collectionPath
);
try {
response = await axiosInstance(request);
} catch (error) {
if (error?.response) {
response = error.response;
} else {
return Promise.reject(error);
}
}
const { data } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
await runPostResponse(
request,
response,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
};
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('clear-oauth2-cache', async (event, uid) => {
ipcMain.handle('clear-oauth2-cache', async (event, uid, url, credentialsId) => {
return new Promise((resolve, reject) => {
try {
const oauth2Store = new Oauth2Store();
oauth2Store.clearSessionIdOfCollection(uid);
oauth2Store.clearSessionIdOfCollection({ collectionUid: uid, url, credentialsId });
resolve();
} catch (err) {
reject(new Error('Could not clear oauth2 cache'));
@@ -1140,17 +992,23 @@ const registerNetworkIpc = (mainWindow) => {
continue;
}
const requestData = request.mode == 'file'? "<request body redacted>": (typeof request?.data === 'string' ? request?.data : safeStringifyJSON(request?.data));
let requestSent = {
url: request.url,
method: request.method,
headers: request.headers,
data: requestData
}
if (requestData) {
requestSent.dataBuffer = Buffer.from(requestData);
}
// todo:
// i have no clue why electron can't send the request object
// without safeParseJSON(safeStringifyJSON(request.data))
mainWindow.webContents.send('main:run-folder-event', {
type: 'request-sent',
requestSent: {
url: request.url,
method: request.method,
headers: request.headers,
data: safeParseJSON(safeStringifyJSON(request.data))
},
requestSent,
...eventData
});
@@ -1164,6 +1022,15 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath
);
if (request?.oauth2Credentials) {
mainWindow.webContents.send('main:credentials-update', {
credentials: request?.oauth2Credentials?.credentials,
url: request?.oauth2Credentials?.url,
collectionUid,
credentialsId: request?.oauth2Credentials?.credentialsId
});
}
timeStart = Date.now();
let response, responseTime;
try {
@@ -1444,3 +1311,4 @@ const registerNetworkIpc = (mainWindow) => {
module.exports = registerNetworkIpc;
module.exports.configureRequest = configureRequest;
module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig;

View File

@@ -15,6 +15,7 @@ const getContentType = (headers = {}) => {
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
@@ -45,6 +46,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
...envVariables,
...folderVariables,
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
process: {
env: {
@@ -151,62 +153,64 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
if (request.auth) {
const username = _interpolate(request.auth.username) || '';
const password = _interpolate(request.auth.password) || '';
if (request.basicAuth) {
const username = _interpolate(request.basicAuth.username) || '';
const password = _interpolate(request.basicAuth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
delete request.auth;
delete request.basicAuth;
}
if (request?.oauth2?.grantType) {
let username, password, scope, clientId, clientSecret;
switch (request.oauth2.grantType) {
case 'password':
username = _interpolate(request.oauth2.username) || '';
password = _interpolate(request.oauth2.password) || '';
clientId = _interpolate(request.oauth2.clientId) || '';
clientSecret = _interpolate(request.oauth2.clientSecret) || '';
scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.username = username;
request.oauth2.password = password;
request.oauth2.clientId = clientId;
request.oauth2.clientSecret = clientSecret;
request.oauth2.scope = scope;
request.data = {
grant_type: 'password',
username,
password,
client_id: clientId,
client_secret: clientSecret,
scope
};
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.username = _interpolate(request.oauth2.username) || '';
request.oauth2.password = _interpolate(request.oauth2.password) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
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) || '';
request.oauth2.authorizationUrl = _interpolate(request.oauth2.authorizationUrl) || '';
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.state = _interpolate(request.oauth2.state) || '';
request.oauth2.pkce = _interpolate(request.oauth2.pkce) || false;
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken);
request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken);
break;
case 'client_credentials':
clientId = _interpolate(request.oauth2.clientId) || '';
clientSecret = _interpolate(request.oauth2.clientSecret) || '';
scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.clientId = clientId;
request.oauth2.clientSecret = clientSecret;
request.oauth2.scope = scope;
request.data = {
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope
};
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken);
request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken);
break;
default:
break;
@@ -243,6 +247,8 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
if(request?.auth) delete request.auth;
return request;
};

View File

@@ -1,129 +0,0 @@
const { get, cloneDeep } = require('lodash');
const crypto = require('crypto');
const { authorizeUserInWindow } = require('./authorize-user-in-window');
const Oauth2Store = require('../../store/oauth2');
const generateCodeVerifier = () => {
return crypto.randomBytes(22).toString('hex');
};
const generateCodeChallenge = (codeVerifier) => {
const hash = crypto.createHash('sha256');
hash.update(codeVerifier);
const base64Hash = hash.digest('base64');
return base64Hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};
// AUTHORIZATION CODE
const resolveOAuth2AuthorizationCodeAccessToken = async (request, collectionUid) => {
let codeVerifier = generateCodeVerifier();
let codeChallenge = generateCodeChallenge(codeVerifier);
let requestCopy = cloneDeep(request);
const { authorizationCode } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge, collectionUid);
const oAuth = get(requestCopy, 'oauth2', {});
const { clientId, clientSecret, callbackUrl, scope, pkce } = oAuth;
const data = {
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: callbackUrl,
client_id: clientId,
client_secret: clientSecret
};
if (pkce) {
data['code_verifier'] = codeVerifier;
}
const url = requestCopy?.oauth2?.accessTokenUrl;
return {
data,
url
};
};
const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
return new Promise(async (resolve, reject) => {
const { oauth2 } = request;
const { callbackUrl, clientId, authorizationUrl, scope, state, pkce } = oauth2;
const authorizationUrlWithQueryParams = new URL(authorizationUrl);
authorizationUrlWithQueryParams.searchParams.append('response_type', 'code');
authorizationUrlWithQueryParams.searchParams.append('client_id', clientId);
if (callbackUrl) {
authorizationUrlWithQueryParams.searchParams.append('redirect_uri', callbackUrl);
}
if (scope) {
authorizationUrlWithQueryParams.searchParams.append('scope', scope);
}
if (pkce) {
authorizationUrlWithQueryParams.searchParams.append('code_challenge', codeChallenge);
authorizationUrlWithQueryParams.searchParams.append('code_challenge_method', 'S256');
}
if (state) {
authorizationUrlWithQueryParams.searchParams.append('state', state);
}
try {
const oauth2Store = new Oauth2Store();
const { authorizationCode } = await authorizeUserInWindow({
authorizeUrl: authorizationUrlWithQueryParams.toString(),
callbackUrl,
session: oauth2Store.getSessionIdOfCollection(collectionUid)
});
resolve({ authorizationCode });
} catch (err) {
reject(err);
}
});
};
// CLIENT CREDENTIALS
const transformClientCredentialsRequest = async (request) => {
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
const { clientId, clientSecret, scope } = oAuth;
const data = {
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret
};
if (scope) {
data.scope = scope;
}
const url = requestCopy?.oauth2?.accessTokenUrl;
return {
data,
url
};
};
// PASSWORD CREDENTIALS
const transformPasswordCredentialsRequest = async (request) => {
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
const { username, password, clientId, clientSecret, scope } = oAuth;
const data = {
grant_type: 'password',
username,
password,
client_id: clientId,
client_secret: clientSecret
};
if (scope) {
data.scope = scope;
}
const url = requestCopy?.oauth2?.accessTokenUrl;
return {
data,
url
};
};
module.exports = {
resolveOAuth2AuthorizationCodeAccessToken,
getOAuth2AuthorizationCode,
transformClientCredentialsRequest,
transformPasswordCredentialsRequest
};

View File

@@ -1,52 +0,0 @@
const { get, each } = require('lodash');
const { setAuthHeaders } = require('./prepare-request');
const prepareCollectionRequest = (request, collection) => {
const collectionRoot = get(collection, 'root', {});
const headers = {};
let contentTypeDefined = false;
let url = request.url;
// collection headers
each(get(collectionRoot, 'request.headers', []), (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
}
});
each(request.headers, (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
}
});
let axiosRequest = {
mode: request?.body?.mode,
method: request.method,
url,
headers,
responseType: 'arraybuffer'
};
axiosRequest = setAuthHeaders(axiosRequest, request, collectionRoot);
axiosRequest.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
if (request.script) {
axiosRequest.script = request.script;
}
axiosRequest.vars = request.vars;
axiosRequest.method = 'POST';
return axiosRequest;
};
module.exports = prepareCollectionRequest;

View File

@@ -2,12 +2,11 @@ const { get, each, filter, find } = require('lodash');
const decomment = require('decomment');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection');
const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials, mergeAuth } = require('../../utils/collection');
const { buildFormUrlEncodedPayload } = require('../../utils/form-data');
const path = require('node:path');
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
const collectionAuth = get(collectionRoot, 'request.auth');
if (collectionAuth && request.auth.mode === 'inherit') {
switch (collectionAuth.mode) {
@@ -22,7 +21,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'basic':
axiosRequest.auth = {
axiosRequest.basicAuth = {
username: get(collectionAuth, 'basic.username'),
password: get(collectionAuth, 'basic.password')
};
@@ -42,7 +41,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(collectionAuth, 'ntlm.password'),
domain: get(collectionAuth, 'ntlm.domain')
};
break;
break;
case 'wsse':
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
@@ -69,6 +68,68 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
}
break;
case 'oauth2':
const grantType = get(collectionAuth, 'oauth2.grantType');
switch (grantType) {
case 'password':
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
username: get(collectionAuth, 'oauth2.username'),
password: get(collectionAuth, 'oauth2.password'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken')
};
break;
case 'authorization_code':
axiosRequest.oauth2 = {
grantType: grantType,
callbackUrl: get(collectionAuth, 'oauth2.callbackUrl'),
authorizationUrl: get(collectionAuth, 'oauth2.authorizationUrl'),
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
state: get(collectionAuth, 'oauth2.state'),
pkce: get(collectionAuth, 'oauth2.pkce'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken')
};
break;
case 'client_credentials':
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken')
};
break;
}
break;
}
}
@@ -85,7 +146,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'basic':
axiosRequest.auth = {
axiosRequest.basicAuth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
@@ -105,7 +166,6 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(request, 'auth.ntlm.password'),
domain: get(request, 'auth.ntlm.domain')
};
break;
case 'oauth2':
const grantType = get(request, 'auth.oauth2.grantType');
switch (grantType) {
@@ -113,11 +173,19 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
username: get(request, 'auth.oauth2.username'),
password: get(request, 'auth.oauth2.password'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope')
scope: get(request, 'auth.oauth2.scope'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
credentialsId: get(request, 'auth.oauth2.credentialsId'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken')
};
break;
case 'authorization_code':
@@ -126,20 +194,36 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
callbackUrl: get(request, 'auth.oauth2.callbackUrl'),
authorizationUrl: get(request, 'auth.oauth2.authorizationUrl'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
state: get(request, 'auth.oauth2.state'),
pkce: get(request, 'auth.oauth2.pkce')
pkce: get(request, 'auth.oauth2.pkce'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
credentialsId: get(request, 'auth.oauth2.credentialsId'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken')
};
break;
case 'client_credentials':
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope')
scope: get(request, 'auth.oauth2.scope'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
credentialsId: get(request, 'auth.oauth2.credentialsId'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken')
};
break;
}
@@ -178,7 +262,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
const prepareRequest = async (item, collection = {}, abortController) => {
const request = item.draft ? item.draft.request : item.request;
const collectionRoot = get(collection, 'root', {});
const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {});
const collectionPath = collection?.pathname;
const headers = {};
let contentTypeDefined = false;
@@ -197,7 +281,9 @@ const prepareRequest = async (item, collection = {}, abortController) => {
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 });
}
@@ -319,10 +405,14 @@ const prepareRequest = async (item, collection = {}, abortController) => {
axiosRequest.folderVariables = request.folderVariables;
axiosRequest.requestVariables = request.requestVariables;
axiosRequest.globalEnvironmentVariables = request.globalEnvironmentVariables;
axiosRequest.oauth2CredentialVariables = request.oauth2CredentialVariables;
axiosRequest.assertions = request.assertions;
axiosRequest.oauth2Credentials = request.oauth2Credentials;
return axiosRequest;
};
module.exports = prepareRequest;
module.exports.setAuthHeaders = setAuthHeaders;
module.exports = {
prepareRequest,
setAuthHeaders
}

View File

@@ -1,24 +1,42 @@
const _ = require('lodash');
const Store = require('electron-store');
const { uuid } = require('../utils/common');
const { uuid, safeStringifyJSON, safeParseJSON } = require('../utils/common');
const { encryptString, decryptString } = require('../utils/encryption');
/**
* Sample secrets store file
*
* {
* "collections": [{
* "path": "/Users/anoop/Code/acme-acpi-collection",
* "environments" : [{
* "name": "Local",
* "secrets": [{
* "name": "token",
* "value": "abracadabra"
* }]
* }]
* }]
* }
*/
class Oauth2Store {
constructor() {
this.store = new Store({
name: 'preferences',
name: 'oauth2',
clearInvalidConfig: true
});
}
// Get oauth2 data for all collections
getAllOauth2Data() {
let oauth2Data = this.store.get('oauth2');
let oauth2Data = this.store.get('collections');
if (!Array.isArray(oauth2Data)) oauth2Data = [];
return oauth2Data;
}
// Get oauth2 data for a collection
getOauth2DataOfCollection(collectionUid) {
getOauth2DataOfCollection({ collectionUid, url }) {
let oauth2Data = this.getAllOauth2Data();
let oauth2DataForCollection = oauth2Data.find((d) => d?.collectionUid == collectionUid);
@@ -28,7 +46,7 @@ class Oauth2Store {
collectionUid
};
let updatedOauth2Data = [...oauth2Data, newOauth2DataForCollection];
this.store.set('oauth2', updatedOauth2Data);
this.store.set('collections', updatedOauth2Data);
return newOauth2DataForCollection;
}
@@ -37,18 +55,18 @@ class Oauth2Store {
}
// Update oauth2 data of a collection
updateOauth2DataOfCollection(collectionUid, data) {
updateOauth2DataOfCollection({ collectionUid, url, data }) {
let oauth2Data = this.getAllOauth2Data();
let updatedOauth2Data = oauth2Data.filter((d) => d.collectionUid !== collectionUid);
updatedOauth2Data.push({ ...data });
this.store.set('oauth2', updatedOauth2Data);
this.store.set('collections', updatedOauth2Data);
}
// Create a new oauth2 Session ID for a collection
createNewOauth2SessionIdForCollection(collectionUid) {
let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid);
// Create a new oauth2 Session Id for a collection
createNewOauth2SessionIdForCollection({ collectionUid, url }) {
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let newSessionId = uuid();
@@ -57,21 +75,21 @@ class Oauth2Store {
sessionId: newSessionId
};
this.updateOauth2DataOfCollection(collectionUid, newOauth2DataForCollection);
this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
return newOauth2DataForCollection;
}
// Get session id of a collection
getSessionIdOfCollection(collectionUid) {
getSessionIdOfCollection({ collectionUid, url }) {
try {
let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid);
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
if (oauth2DataForCollection?.sessionId && typeof oauth2DataForCollection.sessionId === 'string') {
return oauth2DataForCollection.sessionId;
}
let newOauth2DataForCollection = this.createNewOauth2SessionIdForCollection(collectionUid);
let newOauth2DataForCollection = this.createNewOauth2SessionIdForCollection({ collectionUid, url });
return newOauth2DataForCollection?.sessionId;
} catch (err) {
console.log('error retrieving session id from cache', err);
@@ -79,21 +97,71 @@ class Oauth2Store {
}
// clear session id of a collection
clearSessionIdOfCollection(collectionUid) {
clearSessionIdOfCollection({ collectionUid, url }) {
try {
let oauth2Data = this.getAllOauth2Data();
let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid);
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
delete oauth2DataForCollection.sessionId;
delete oauth2DataForCollection.credentials;
let updatedOauth2Data = oauth2Data.filter((d) => d.collectionUid !== collectionUid);
updatedOauth2Data.push({ ...oauth2DataForCollection });
this.store.set('oauth2', updatedOauth2Data);
this.store.set('collections', updatedOauth2Data);
} catch (err) {
console.log('error while clearing the oauth2 session cache', err);
}
}
getCredentialsForCollection({ collectionUid, url, credentialsId }) {
try {
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let credentials = oauth2DataForCollection?.credentials?.find(c => (c?.url == url) && (c?.credentialsId == credentialsId));
if (!credentials?.data) return null;
let decryptedCredentialsData = safeParseJSON(decryptString(credentials?.data));
return decryptedCredentialsData;
} catch (err) {
console.log('error retrieving oauth2 credentials from cache', err);
}
}
updateCredentialsForCollection({ collectionUid, url, credentialsId, credentials = {} }) {
try {
let encryptedCredentialsData = encryptString(safeStringifyJSON(credentials));
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let filteredCredentials = oauth2DataForCollection?.credentials?.filter(c => (c?.url !== url) || (c?.credentialsId !== credentialsId));
if (!filteredCredentials) filteredCredentials = [];
filteredCredentials.push({
url,
data: encryptedCredentialsData,
credentialsId
});
let newOauth2DataForCollection = {
...oauth2DataForCollection,
credentials: filteredCredentials
};
this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
return newOauth2DataForCollection;
} catch (err) {
console.log('error updating oauth2 credentials from cache', err);
}
}
clearCredentialsForCollection({ collectionUid, url, credentialsId }) {
try {
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let filteredCredentials = oauth2DataForCollection?.credentials?.filter(c => (c?.url !== url) || (c?.credentialsId !== credentialsId));
let newOauth2DataForCollection = {
...oauth2DataForCollection,
credentials: filteredCredentials
};
this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
return newOauth2DataForCollection;
} catch (err) {
console.log('error clearing oauth2 credentials from cache', err);
}
}
}
module.exports = Oauth2Store;

View File

@@ -1,8 +1,7 @@
const { get, each, find, compact, filter } = require('lodash');
const fs = require('fs');
const { getRequestUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const { get, each, find, compact } = require('lodash');
const os = require('os');
const mergeHeaders = (collection, request, requestTreePath) => {
@@ -264,10 +263,137 @@ const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
const sortCollection = (collection) => {
const items = collection.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
collection.items = folderItems.concat(requestItems);
each(folderItems, (item) => {
sortCollection(item);
});
};
const sortFolder = (folder = {}) => {
const items = folder.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
folder.items = folderItems.concat(requestItems);
each(folderItems, (item) => {
sortFolder(item);
});
return folder;
};
const getAllRequestsInFolderRecursively = (folder = {}) => {
let requests = [];
if (folder.items && folder.items.length) {
folder.items.forEach((item) => {
if (item.type !== 'folder') {
requests.push(item);
} else {
requests = requests.concat(getAllRequestsInFolderRecursively(item));
}
});
}
return requests;
};
const getEnvVars = (environment = {}) => {
const variables = environment.variables;
if (!variables || !variables.length) {
return {
__name__: environment.name
};
}
const envVars = {};
each(variables, (variable) => {
if (variable.enabled) {
envVars[variable.name] = variable.value;
}
});
return {
...envVars,
__name__: environment.name
};
};
const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => {
let credentialsVariables = {};
oauth2Credentials.forEach(({ credentialsId, credentials }) => {
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,
flattenItems,
findItem,
@@ -276,5 +402,10 @@ module.exports = {
findItemInCollectionByPathname,
findParentItemInCollection,
parseBruFileMeta,
sortCollection,
sortFolder,
getAllRequestsInFolderRecursively,
getEnvVars,
getFormattedCollectionOauth2Credentials,
hydrateRequestWithUuid
};

View File

@@ -1,4 +1,5 @@
const { customAlphabet } = require('nanoid');
const iconv = require('iconv-lite');
// a customized version of nanoid without using _ and -
const uuid = () => {
@@ -85,6 +86,32 @@ const flattenDataForDotNotation = (data) => {
return result;
};
const parseDataFromResponse = (response, disableParsingResponseJson = false) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
const charsetValue = charsetMatch?.[1];
const dataBuffer = Buffer.from(response.data);
// Overwrite the original data for backwards compatibility
let data;
if (iconv.encodingExists(charsetValue)) {
data = iconv.decode(dataBuffer, charsetValue);
} else {
data = iconv.decode(dataBuffer, 'utf-8');
}
// Try to parse response to JSON, this can quietly fail
try {
// Filter out ZWNBSP character
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
data = data.replace(/^\uFEFF/, '');
if (!disableParsingResponseJson) {
data = JSON.parse(data);
}
} catch { }
return { data, dataBuffer };
};
module.exports = {
uuid,
stringifyJson,
@@ -93,5 +120,6 @@ module.exports = {
safeParseJSON,
simpleHash,
generateUidBasedOnHash,
flattenDataForDotNotation
flattenDataForDotNotation,
parseDataFromResponse
};

View File

@@ -0,0 +1,806 @@
const { get, cloneDeep } = require('lodash');
const crypto = require('crypto');
const { authorizeUserInWindow } = require('../ipc/network/authorize-user-in-window');
const Oauth2Store = require('../store/oauth2');
const { makeAxiosInstance } = require('../ipc/network/axios-instance');
const { safeParseJSON, safeStringifyJSON } = require('./common');
const qs = require('qs');
const oauth2Store = new Oauth2Store();
const persistOauth2Credentials = ({ collectionUid, url, credentials, credentialsId }) => {
if (credentials?.error || !credentials?.access_token) return;
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 }) => {
try {
const credentials = oauth2Store.getCredentialsForCollection({ collectionUid, url, credentialsId });
return credentials;
}
catch(error) {
return null;
}
};
const isTokenExpired = (credentials) => {
if (!credentials?.access_token) {
return true;
}
if (!credentials?.expires_in || !credentials.created_at) {
return false;
}
const expiryTime = credentials.created_at + credentials.expires_in * 1000;
return Date.now() > expiryTime;
};
// AUTHORIZATION CODE
const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
let codeVerifier = generateCodeVerifier();
let codeChallenge = generateCodeChallenge(codeVerifier);
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
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 (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, certsAndProxyConfig });
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 };
}
}
}
// 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") {
requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
const data = {
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: callbackUrl,
client_id: clientId,
};
if (clientSecret && credentialsPlacement !== "basic_auth_header") {
data.client_secret = clientSecret;
}
if (pkce) {
data['code_verifier'] = codeVerifier;
}
if (scope) {
data.scope = scope;
}
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
try {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
// Interceptor to capture request data
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
if (error.response) {
axiosResponseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
};
}
else if(error?.code) {
axiosResponseInfo = {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
const response = await axiosInstance(requestCopy);
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(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
error: null
},
response: {
url: axiosResponseInfo?.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
error: axiosResponseInfo?.error,
timeline: axiosResponseInfo?.timeline
},
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, debugInfo };
} catch (error) {
return Promise.reject(safeStringifyJSON(error?.response?.data));
}
};
const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
return new Promise(async (resolve, reject) => {
const { oauth2 } = request;
const { callbackUrl, clientId, authorizationUrl, scope, state, pkce, accessTokenUrl } = oauth2;
const authorizationUrlWithQueryParams = new URL(authorizationUrl);
authorizationUrlWithQueryParams.searchParams.append('response_type', 'code');
authorizationUrlWithQueryParams.searchParams.append('client_id', clientId);
if (callbackUrl) {
authorizationUrlWithQueryParams.searchParams.append('redirect_uri', callbackUrl);
}
if (scope) {
authorizationUrlWithQueryParams.searchParams.append('scope', scope);
}
if (pkce) {
authorizationUrlWithQueryParams.searchParams.append('code_challenge', codeChallenge);
authorizationUrlWithQueryParams.searchParams.append('code_challenge_method', 'S256');
}
if (state) {
authorizationUrlWithQueryParams.searchParams.append('state', state);
}
try {
const authorizeUrl = authorizationUrlWithQueryParams.toString();
const { authorizationCode, debugInfo } = await authorizeUserInWindow({
authorizeUrl,
callbackUrl,
session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl })
});
resolve({ authorizationCode, debugInfo });
} catch (err) {
reject(err);
}
});
};
// CLIENT CREDENTIALS
const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
const {
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
autoRefreshToken,
autoFetchToken,
} = oAuth;
const url = requestCopy?.oauth2?.accessTokenUrl;
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, certsAndProxyConfig });
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") {
requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
const data = {
grant_type: 'client_credentials',
client_id: clientId,
};
if (clientSecret && credentialsPlacement !== "basic_auth_header") {
data.client_secret = clientSecret;
}
if (scope) {
data.scope = scope;
}
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
let debugInfo = { data: [] };
try {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
if (error.response) {
axiosResponseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
};
}
else if(error?.code) {
axiosResponseInfo = {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data.toString() : response.data
);
// Add the axios request and response info as a main request in debugInfo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
error: null
},
response: {
url: axiosResponseInfo.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
timeline: axiosResponseInfo?.timeline,
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, debugInfo };
} catch (error) {
return Promise.reject(safeStringifyJSON(error?.response?.data));
}
};
// PASSWORD CREDENTIALS
const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
const {
username,
password,
clientId,
clientSecret,
scope,
credentialsPlacement,
credentialsId,
autoRefreshToken,
autoFetchToken,
} = oAuth;
const url = requestCopy?.oauth2?.accessTokenUrl;
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, certsAndProxyConfig });
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") {
requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
const data = {
grant_type: 'password',
username,
password,
client_id: clientId,
};
if (clientSecret && credentialsPlacement !== "basic_auth_header") {
data.client_secret = clientSecret;
}
if (scope) {
data.scope = scope;
}
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
let debugInfo = { data: [] };
try {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
if (error.response) {
axiosResponseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
};
}
else if(error?.code) {
axiosResponseInfo = {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data.toString() : response.data
);
// Add the axios request and response info as a main request in debugInfo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
error: null
},
response: {
url: axiosResponseInfo?.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
timeline: axiosResponseInfo?.timeline,
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, debugInfo };
} catch (error) {
return Promise.reject(safeStringifyJSON(error?.response?.data));
}
};
const refreshOauth2Token = async ({ requestCopy, collectionUid, certsAndProxyConfig }) => {
const oAuth = get(requestCopy, 'oauth2', {});
const { clientId, clientSecret, credentialsId } = oAuth;
const url = oAuth.refreshTokenUrl ? oAuth.refreshTokenUrl : oAuth.accessTokenUrl;
const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId });
if (!credentials?.refresh_token) {
clearOauth2Credentials({ collectionUid, url, credentialsId });
// Proceed without token
return { collectionUid, url, credentials: null, credentialsId };
} else {
const data = {
grant_type: 'refresh_token',
client_id: clientId,
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';
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
let debugInfo = { data: [] };
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
if (error.response) {
axiosResponseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
};
}
else if(error?.code) {
axiosResponseInfo = {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
try {
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data.toString() : response.data
);
// Add the axios request and response info as a main request in debugInfo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
error: null
},
response: {
url: axiosResponseInfo?.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
timeline: axiosResponseInfo?.timeline,
error: null
},
fromCache: false,
completed: true,
requests: [], // No sub-requests in this context
};
debugInfo.data.push(axiosMainRequest);
if (parsedResponseData?.error) {
clearOauth2Credentials({ collectionUid, url, credentialsId });
return { collectionUid, url, credentials: null, credentialsId, debugInfo };
}
persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
} catch (error) {
clearOauth2Credentials({ collectionUid, url, credentialsId });
// Proceed without token
return { collectionUid, url, credentials: null, credentialsId, debugInfo };
}
}
};
// HELPER FUNCTIONS
const generateCodeVerifier = () => {
return crypto.randomBytes(22).toString('hex');
};
const generateCodeChallenge = (codeVerifier) => {
const hash = crypto.createHash('sha256');
hash.update(codeVerifier);
const base64Hash = hash.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return base64Hash;
};
module.exports = {
getOAuth2TokenUsingAuthorizationCode,
getOAuth2AuthorizationCode,
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,292 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
function createTimelineAgentClass(BaseAgentClass) {
return class extends BaseAgentClass {
constructor(options, timeline) {
// For proxy agents, the first argument is the proxy URI and the second is options
if (options?.proxy) {
const { proxy: proxyUri, ...agentOptions } = options;
// Ensure TLS options are properly set
const tlsOptions = {
...agentOptions,
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true,
};
super(proxyUri, tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!tlsOptions.ca;
// Log TLS verification status
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`,
});
// Log the proxy details
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${proxyUri}`,
});
} else {
// This is a regular HTTPS agent case
const tlsOptions = {
...options,
rejectUnauthorized: options.rejectUnauthorized ?? true,
};
super(tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!options.ca;
// Log TLS verification status
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`,
});
}
}
createConnection(options, callback) {
const { host, port } = options;
// 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,
}) {
// Ensure TLS options are properly set
const tlsOptions = {
...httpsAgentRequestFields,
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
};
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, ...tlsOptions }, timeline);
} else {
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
requestConfig.httpAgent = new HttpProxyAgent(proxyUri); // For http, no need for timeline
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
{ proxy: proxyUri, ...tlsOptions },
timeline
);
}
} else {
// If proxy should not be used, set default HTTPS agent
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, 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(PatchedHttpsProxyAgent);
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
{ proxy: https_proxy,...tlsOptions },
timeline
);
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
}
module.exports = {
shouldUseProxy,
PatchedHttpsProxyAgent
PatchedHttpsProxyAgent,
setupProxyAgents
};

View File

@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
const prepareRequest = require('../../src/ipc/network/prepare-request');
const { prepareRequest } = require('../../src/ipc/network/prepare-request');
const { buildFormUrlEncodedPayload } = require('../../src/utils/form-data');
describe('prepare-request: prepareRequest', () => {

View File

@@ -4,7 +4,7 @@ const { interpolate } = require('@usebruno/common');
const variableNameRegex = /^[\w-.]*$/;
class Bru {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables) {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables) {
this.envVariables = envVariables || {};
this.runtimeVariables = runtimeVariables || {};
this.processEnvVars = cloneDeep(processEnvVars || {});
@@ -12,6 +12,7 @@ class Bru {
this.folderVariables = folderVariables || {};
this.requestVariables = requestVariables || {};
this.globalEnvironmentVariables = globalEnvironmentVariables || {};
this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
this.runner = {
skipRequest: () => {
@@ -37,6 +38,7 @@ class Bru {
...this.envVariables,
...this.folderVariables,
...this.requestVariables,
...this.oauth2CredentialVariables,
...this.runtimeVariables,
process: {
env: {
@@ -92,6 +94,10 @@ class Bru {
this.globalEnvironmentVariables[key] = value;
}
getOauth2CredentialVar(key) {
return this._interpolate(this.oauth2CredentialVariables[key]);
}
hasVar(key) {
return Object.hasOwn(this.runtimeVariables, key);
}

View File

@@ -246,6 +246,7 @@ class AssertRuntime {
runAssertions(assertions, request, response, envVariables, runtimeVariables, processEnvVars) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
@@ -279,6 +280,7 @@ class AssertRuntime {
...envVariables,
...folderVariables,
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
...processEnvVars,
...bruContext

View File

@@ -51,10 +51,11 @@ class ScriptRuntime {
runRequestByItemPathname
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables);
const req = new BrunoRequest(request);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
@@ -122,6 +123,7 @@ class ScriptRuntime {
sandbox: context,
require: {
context: 'sandbox',
builtin: [ "*" ],
external: true,
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
@@ -182,10 +184,11 @@ class ScriptRuntime {
runRequestByItemPathname
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
@@ -250,6 +253,7 @@ class ScriptRuntime {
sandbox: context,
require: {
context: 'sandbox',
builtin: [ "*" ],
external: true,
root: [collectionPath],
mock: {

View File

@@ -53,6 +53,12 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getGlobalEnvVar', getGlobalEnvVar);
getGlobalEnvVar.dispose();
let getOauth2CredentialVar = vm.newFunction('getOauth2CredentialVar', function (key) {
return marshallToVm(bru.getOauth2CredentialVar(vm.dump(key)), vm);
});
vm.setProp(bruObject, 'getOauth2CredentialVar', getOauth2CredentialVar);
getOauth2CredentialVar.dispose();
let setGlobalEnvVar = vm.newFunction('setGlobalEnvVar', function (key, value) {
bru.setGlobalEnvVar(vm.dump(key), vm.dump(value));
});

View File

@@ -1,6 +1,6 @@
const ohm = require('ohm-js');
const _ = require('lodash');
const { outdentString } = require('../../v1/src/utils');
const { safeParseJson, outdentString } = require('./utils');
/**
* A Bru file is made up of blocks.
@@ -514,11 +514,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
const callbackUrlKey = _.find(auth, { name: 'callback_url' });
const authorizationUrlKey = _.find(auth, { name: 'authorization_url' });
const accessTokenUrlKey = _.find(auth, { name: 'access_token_url' });
const refreshTokenUrlKey = _.find(auth, { name: 'refresh_token_url' });
const clientIdKey = _.find(auth, { name: 'client_id' });
const clientSecretKey = _.find(auth, { name: 'client_secret' });
const scopeKey = _.find(auth, { name: 'scope' });
const stateKey = _.find(auth, { name: 'state' });
const pkceKey = _.find(auth, { name: 'pkce' });
const credentialsPlacementKey = _.find(auth, { name: 'credentials_placement' });
const credentialsIdKey = _.find(auth, { name: 'credentials_id' });
const tokenPlacementKey = _.find(auth, { name: 'token_placement' });
const tokenHeaderPrefixKey = _.find(auth, { name: 'token_header_prefix' });
const tokenQueryKeyKey = _.find(auth, { name: 'token_query_key' });
const autoFetchTokenKey = _.find(auth, { name: 'auto_fetch_token' });
const autoRefreshTokenKey = _.find(auth, { name: 'auto_refresh_token' });
return {
auth: {
oauth2:
@@ -526,11 +534,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
username: usernameKey ? usernameKey.value : '',
password: passwordKey ? passwordKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : ''
scope: scopeKey ? scopeKey.value : '',
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'authorization_code'
? {
@@ -538,19 +554,35 @@ const sem = grammar.createSemantics().addAttribute('ast', {
callbackUrl: callbackUrlKey ? callbackUrlKey.value : '',
authorizationUrl: authorizationUrlKey ? authorizationUrlKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : '',
state: stateKey ? stateKey.value : '',
pkce: pkceKey ? JSON.parse(pkceKey?.value || false) : false
pkce: pkceKey ? safeParseJson(pkceKey?.value) ?? false : false,
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'client_credentials'
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : ''
scope: scopeKey ? scopeKey.value : '',
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: {}
}

View File

@@ -1,6 +1,6 @@
const ohm = require('ohm-js');
const _ = require('lodash');
const { outdentString } = require('../../v1/src/utils');
const { safeParseJson, outdentString } = require('./utils');
const grammar = ohm.grammar(`Bru {
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
@@ -274,11 +274,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
const callbackUrlKey = _.find(auth, { name: 'callback_url' });
const authorizationUrlKey = _.find(auth, { name: 'authorization_url' });
const accessTokenUrlKey = _.find(auth, { name: 'access_token_url' });
const refreshTokenUrlKey = _.find(auth, { name: 'refresh_token_url' });
const clientIdKey = _.find(auth, { name: 'client_id' });
const clientSecretKey = _.find(auth, { name: 'client_secret' });
const scopeKey = _.find(auth, { name: 'scope' });
const stateKey = _.find(auth, { name: 'state' });
const pkceKey = _.find(auth, { name: 'pkce' });
const credentialsPlacementKey = _.find(auth, { name: 'credentials_placement' });
const credentialsIdKey = _.find(auth, { name: 'credentials_id' });
const tokenPlacementKey = _.find(auth, { name: 'token_placement' });
const tokenHeaderPrefixKey = _.find(auth, { name: 'token_header_prefix' });
const tokenQueryKeyKey = _.find(auth, { name: 'token_query_key' });
const autoFetchTokenKey = _.find(auth, { name: 'auto_fetch_token' });
const autoRefreshTokenKey = _.find(auth, { name: 'auto_refresh_token' });
return {
auth: {
oauth2:
@@ -286,11 +294,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
username: usernameKey ? usernameKey.value : '',
password: passwordKey ? passwordKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : ''
scope: scopeKey ? scopeKey.value : '',
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'authorization_code'
? {
@@ -298,19 +314,35 @@ const sem = grammar.createSemantics().addAttribute('ast', {
callbackUrl: callbackUrlKey ? callbackUrlKey.value : '',
authorizationUrl: authorizationUrlKey ? authorizationUrlKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : '',
state: stateKey ? stateKey.value : '',
pkce: pkceKey ? JSON.parse(pkceKey?.value || false) : false
pkce: pkceKey ? safeParseJson(pkceKey?.value) ?? false : false,
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'client_credentials'
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : ''
scope: scopeKey ? scopeKey.value : '',
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: {}
}

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const { indentString } = require('../../v1/src/utils');
const { indentString } = require('./utils');
const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]);
const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]);
@@ -183,11 +183,21 @@ ${indentString(`domain: ${auth?.ntlm?.domain || ''}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: password`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`username: ${auth?.oauth2?.username || ''}`)}
${indentString(`password: ${auth?.oauth2?.password || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
}${
auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
}
${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -198,11 +208,21 @@ ${indentString(`grant_type: authorization_code`)}
${indentString(`callback_url: ${auth?.oauth2?.callbackUrl || ''}`)}
${indentString(`authorization_url: ${auth?.oauth2?.authorizationUrl || ''}`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
${indentString(`state: ${auth?.oauth2?.state || ''}`)}
${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
}${
auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
}
${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -211,9 +231,19 @@ ${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: client_credentials`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
}${
auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
}
${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const { indentString } = require('../../v1/src/utils');
const { indentString } = require('./utils');
const enabled = (items = []) => items.filter((item) => item.enabled);
const disabled = (items = []) => items.filter((item) => !item.enabled);
@@ -149,11 +149,21 @@ ${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: password`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`username: ${auth?.oauth2?.username || ''}`)}
${indentString(`password: ${auth?.oauth2?.password || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
}${
auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
}
${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -164,11 +174,21 @@ ${indentString(`grant_type: authorization_code`)}
${indentString(`callback_url: ${auth?.oauth2?.callbackUrl || ''}`)}
${indentString(`authorization_url: ${auth?.oauth2?.authorizationUrl || ''}`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
${indentString(`state: ${auth?.oauth2?.state || ''}`)}
${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
}${
auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
}
${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -177,9 +197,19 @@ ${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: client_credentials`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
}${
auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
}
${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;

View File

@@ -0,0 +1,36 @@
// safely parse json
const safeParseJson = (json) => {
try {
return JSON.parse(json);
} catch (e) {
return null;
}
};
const indentString = (str) => {
if (!str || !str.length) {
return str || '';
}
return str
.split('\n')
.map((line) => ' ' + line)
.join('\n');
};
const outdentString = (str) => {
if (!str || !str.length) {
return str || '';
}
return str
.split('\n')
.map((line) => line.replace(/^ /, ''))
.join('\n');
};
module.exports = {
safeParseJson,
indentString,
outdentString
};

View File

@@ -59,11 +59,18 @@ auth:oauth2 {
callback_url: http://localhost:8080/api/auth/oauth2/authorization_code/callback
authorization_url: http://localhost:8080/api/auth/oauth2/authorization_code/authorize
access_token_url: http://localhost:8080/api/auth/oauth2/authorization_code/token
refresh_token_url:
client_id: client_id_1
client_secret: client_secret_1
scope: read write
state: 807061d5f0be
pkce: false
credentials_placement: body
credentials_id: credentials
token_placement: header
token_header_prefix: Bearer
auto_fetch_token: true
auto_refresh_token: true
}
body:json {

View File

@@ -74,15 +74,23 @@
"password": "secret"
},
"oauth2": {
"grantType": "authorization_code",
"accessTokenUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/token",
"authorizationUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/authorize",
"autoFetchToken": true,
"autoRefreshToken": true,
"callbackUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/callback",
"clientId": "client_id_1",
"clientSecret": "client_secret_1",
"authorizationUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/authorize",
"callbackUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/callback",
"accessTokenUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/token",
"credentialsId": "credentials",
"credentialsPlacement": "body",
"grantType": "authorization_code",
"pkce": false,
"refreshTokenUrl": "",
"scope": "read write",
"state": "807061d5f0be",
"pkce": false
"tokenHeaderPrefix": "Bearer",
"tokenPlacement": "header",
"tokenQueryKey": "access_token"
},
"wsse": {
"username": "john",

View File

@@ -210,6 +210,48 @@ const oauth2Schema = Yup.object({
is: (val) => ['authorization_code'].includes(val),
then: Yup.boolean().default(false),
otherwise: Yup.boolean()
}),
credentialsPlacement: Yup.string().when('grantType', {
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
credentialsId: Yup.string().when('grantType', {
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
tokenPlacement: Yup.string().when('grantType', {
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
tokenHeaderPrefix: Yup.string().when(['grantType', 'tokenPlacement'], {
is: (grantType, tokenPlacement) =>
['client_credentials', 'password', 'authorization_code'].includes(grantType) && tokenPlacement === 'header',
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
tokenQueryKey: Yup.string().when(['grantType', 'tokenPlacement'], {
is: (grantType, tokenPlacement) =>
['client_credentials', 'password', 'authorization_code'].includes(grantType) && tokenPlacement === 'url',
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
refreshTokenUrl: Yup.string().when('grantType', {
is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
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)

View File

@@ -43,6 +43,5 @@ tests {
expect(res.getBody()).to.eql({
"hello": "bruno"
});
});
});
}

Some files were not shown because too many files have changed in this diff Show More