Feat/api key auth (#2478)

* feat: Added ApiKeyAuth component

* feat: Add support for API Key authentication

- Added the ApiKeyAuth component to handle API Key authentication mode.
- Updated the AuthMode component to include an option for API Key authentication.
- Updated the collections schema to include validation for API Key authentication.
- Updated the collectionsSlice to handle API Key authentication in the Redux store.

* refactor: input value handlers

- Removed the separate handleKeyChange, handlePlacementChange and handleValueChange functions and consolidated them into handleAuthChange.

* feat: Update prepare-request to handle API Key authentication in query parameters

* refactor: handling the queryparams placement api key values in the ConfigureRequest function

* refactor: added collection level api key auth

* refactor: updated collection export function

* refactor: add default placement for API key authentication in ApiKeyAuth component

* refactor: add default placement for API key authentication in ApiKeyAuth component in CollectionSettings

* refactor: update generateAuth function to handle API key authentication in postman collection exporter

* refactor: fix typo in API key placement for collection export

* Made minor changes in the logic.

* Updated the importers for postman to handle new auth type.
This commit is contained in:
Sanjai Kumar 2024-09-20 17:28:53 +05:30 committed by GitHub
parent b60c799645
commit 637e53421e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 535 additions and 6 deletions

View File

@ -0,0 +1,56 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.auth-placement-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;
}
}
}
.auth-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;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@ -0,0 +1,109 @@
import React, { useRef, forwardRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = get(collection, 'root.request.auth.apikey', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleAuthChange = (property, value) => {
dispatch(
updateCollectionAuth({
mode: 'apikey',
collectionUid: collection.uid,
content: {
...apikeyAuth,
[property]: value
}
})
);
};
useEffect(() => {
!apikeyAuth?.placement &&
dispatch(
updateCollectionAuth({
mode: 'apikey',
collectionUid: collection.uid,
content: {
placement: 'header'
}
})
);
}, [apikeyAuth]);
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Key</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.key || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('key', val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Value</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('value', val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Add To</label>
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default ApiKeyAuth;

View File

@ -79,6 +79,15 @@ const AuthMode = ({ collection }) => {
>
Oauth2
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -6,6 +6,7 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import ApiKeyAuth from './ApiKeyAuth/';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
@ -33,6 +34,9 @@ const Auth = ({ collection }) => {
case 'oauth2': {
return <OAuth2 collection={collection} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} />;
}
}
};

View File

@ -0,0 +1,57 @@
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};
}
.auth-placement-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;
}
}
}
.auth-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;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@ -0,0 +1,114 @@
import React, { useRef, forwardRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleAuthChange = (property, value) => {
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...apikeyAuth,
[property]: value
}
})
);
};
useEffect(() => {
!apikeyAuth?.placement &&
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
placement: 'header'
}
})
);
}, [apikeyAuth]);
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Key</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.key || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('key', val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Value</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('value', val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Add To</label>
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default ApiKeyAuth;

View File

@ -80,6 +80,15 @@ const AuthMode = ({ item, collection }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -5,6 +5,7 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections/index';
import OAuth2 from './OAuth2/index';
@ -32,6 +33,9 @@ const Auth = ({ item, collection }) => {
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
}
case 'inherit': {
return (
<div className="flex flex-row w-full mt-2 gap-2">

View File

@ -477,6 +477,10 @@ export const collectionsSlice = createSlice({
item.draft.request.auth.mode = 'oauth2';
item.draft.request.auth.oauth2 = action.payload.content;
break;
case 'apikey':
item.draft.request.auth.mode = 'apikey';
item.draft.request.auth.apikey = action.payload.content;
break;
}
}
}
@ -1137,6 +1141,9 @@ export const collectionsSlice = createSlice({
case 'oauth2':
set(collection, 'root.request.auth.oauth2', action.payload.content);
break;
case 'apikey':
set(collection, 'root.request.auth.apikey', action.payload.content);
break;
}
}
},

View File

@ -372,6 +372,14 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
break;
}
break;
case 'apikey':
di.request.auth.apikey = {
key: get(si.request, 'auth.apikey.key', ''),
value: get(si.request, 'auth.apikey.value', ''),
placement: get(si.request, 'auth.apikey.placement', 'header')
};
break;
default:
break;
}
@ -661,6 +669,26 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'OAuth 2.0';
break;
}
case 'apikey': {
label = 'API Key';
break;
}
}
return label;
};
export const humanizeRequestAPIKeyPlacement = (placement) => {
let label = 'Header';
switch (placement) {
case 'header': {
label = 'Header';
break;
}
case 'queryparams': {
label = 'Query Params';
break;
}
}
return label;

View File

@ -246,7 +246,7 @@ export const exportCollection = (collection) => {
};
const generateAuth = (itemAuth) => {
switch (itemAuth) {
switch (itemAuth?.mode) {
case 'bearer':
return {
type: 'bearer',
@ -273,6 +273,27 @@ export const exportCollection = (collection) => {
]
};
}
case 'apikey': {
return {
type: 'apikey',
apikey: [
{
key: 'key',
value: itemAuth.apikey.key,
type: 'string'
},
{
key: 'value',
value: itemAuth.apikey.value,
type: 'string'
}
]
};
}
default: {
console.error('Unsupported auth mode:', itemAuth.mode);
return null;
}
}
};

View File

@ -1,4 +1,3 @@
import each from 'lodash/each';
import get from 'lodash/get';
import fileDialog from 'file-dialog';
import { uuid } from 'utils/common';
@ -292,6 +291,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
region: authValues.region,
profileName: ''
};
} else if (auth.type === 'apikey'){
brunoRequestItem.request.auth.mode = 'apikey';
brunoRequestItem.request.auth.apikey = {
key: authValues.key,
value: authValues.value,
placement: "header" //By default we are placing the apikey values in headers!
}
}
}

View File

@ -317,9 +317,24 @@ const configureRequest = async (
}
}
// Add API key to the URL
if (request.apiKeyAuthValueForQueryParams && request.apiKeyAuthValueForQueryParams.placement === 'queryparams') {
const urlObj = new URL(request.url);
// Interpolate key and value as they can be variables before adding to the URL.
const key = interpolateString(request.apiKeyAuthValueForQueryParams.key, interpolationOptions);
const value = interpolateString(request.apiKeyAuthValueForQueryParams.value, interpolationOptions);
urlObj.searchParams.set(key, value);
request.url = urlObj.toString();
}
// Remove pathParams, already in URL (Issue #2439)
delete request.pathParams;
// Remove apiKeyAuthValueForQueryParams, already interpolated and added to URL
delete request.apiKeyAuthValueForQueryParams;
return axiosInstance;
};

View File

@ -219,6 +219,15 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(collectionAuth, 'digest.password')
};
break;
case 'apikey':
const apiKeyAuth = get(collectionAuth, 'apikey');
if (apiKeyAuth.placement === 'header') {
axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value;
} else if (apiKeyAuth.placement === 'queryparams') {
// If the API key authentication is set and its placement is 'queryparams', add it to the axios request object. This will be used in the configureRequest function to append the API key to the query parameters of the request URL.
axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
}
break;
}
}
@ -287,6 +296,15 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
break;
}
break;
case 'apikey':
const apiKeyAuth = get(request, 'auth.apikey');
if (apiKeyAuth.placement === 'header') {
axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value;
} else if (apiKeyAuth.placement === 'queryparams') {
// If the API key authentication is set and its placement is 'queryparams', add it to the axios request object. This will be used in the configureRequest function to append the API key to the query parameters of the request URL.
axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
}
break;
}
}

View File

@ -23,7 +23,7 @@ const { outdentString } = require('../../v1/src/utils');
*/
const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authapikey
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
params = paramspath | paramsquery
@ -88,6 +88,7 @@ const grammar = ohm.grammar(`Bru {
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
authOAuth2 = "auth:oauth2" dictionary
authapikey = "auth:apikey" dictionary
body = "body" st* "{" nl* textblock tagend
bodyjson = "body:json" st* "{" nl* textblock tagend
@ -483,6 +484,28 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authapikey(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const findValueByName = (name) => {
const item = _.find(auth, { name });
return item ? item.value : '';
};
const key = findValueByName('key');
const value = findValueByName('value');
const placement = findValueByName('placement');
return {
auth: {
apikey: {
key,
value,
placement
}
}
};
},
bodyformurlencoded(_1, dictionary) {
return {
body: {

View File

@ -4,7 +4,7 @@ const { outdentString } = require('../../v1/src/utils');
const grammar = ohm.grammar(`Bru {
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authapikey
nl = "\\r"? "\\n"
st = " " | "\\t"
@ -43,6 +43,7 @@ const grammar = ohm.grammar(`Bru {
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
authOAuth2 = "auth:oauth2" dictionary
authapikey = "auth:apikey" dictionary
script = scriptreq | scriptres
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
@ -293,6 +294,28 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authapikey(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const findValueByName = (name) => {
const item = _.find(auth, { name });
return item ? item.value : '';
};
const key = findValueByName('key');
const value = findValueByName('value');
const placement = findValueByName('placement');
return {
auth: {
apikey: {
key,
value,
placement
}
}
};
},
varsreq(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
_.each(vars, (v) => {

View File

@ -200,6 +200,16 @@ ${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
}
}
if (auth && auth.apikey) {
bru += `auth:apikey {
${indentString(`key: ${auth?.apikey?.key || ''}`)}
${indentString(`value: ${auth?.apikey?.value || ''}`)}
${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
}
`;
}
if (body && body.json && body.json.length) {
bru += `body:json {
${indentString(body.json)}

View File

@ -111,6 +111,15 @@ ${indentString(`username: ${auth.digest.username}`)}
${indentString(`password: ${auth.digest.password}`)}
}
`;
}
if (auth && auth.apikey) {
bru += `auth:apikey {
${indentString(`key: ${auth?.apikey?.key || ''}`)}
${indentString(`value: ${auth?.apikey?.value || ''}`)}
${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
}
`;
}

View File

@ -177,15 +177,22 @@ const oauth2Schema = Yup.object({
.noUnknown(true)
.strict();
const authApiKeySchema = Yup.object({
key: Yup.string().nullable(),
value: Yup.string().nullable(),
placement: Yup.string().oneOf(['header', 'queryparams']).nullable()
});
const authSchema = Yup.object({
mode: Yup.string()
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2'])
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2', 'apikey'])
.required('mode is required'),
awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable(),
digest: authDigestSchema.nullable(),
oauth2: oauth2Schema.nullable()
oauth2: oauth2Schema.nullable(),
apikey: authApiKeySchema.nullable()
})
.noUnknown(true)
.strict()