Added support for AWS Sig V4 Authentication

This commit is contained in:
Brian Rodgers 2023-10-06 08:47:05 -05:00
parent c25542bbdf
commit e2e3895a58
16 changed files with 402 additions and 11 deletions

View File

@ -35,6 +35,15 @@ const AuthMode = ({ item, collection }) => {
<StyledWrapper> <StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector"> <div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end"> <Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
<div <div
className="dropdown-item" className="dropdown-item"
onClick={() => { onClick={() => {

View File

@ -0,0 +1,16 @@
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};
}
`;
export default Wrapper;

View File

@ -0,0 +1,206 @@
import React, { useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
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 { update } from 'lodash';
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
console.log('saved auth', awsv4Auth);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
}
})
);
};
const handleSecretAccessKeyChange = (secretAccessKey) => {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
}
})
);
};
const handleSessionTokenChange = (sessionToken) => {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
}
})
);
};
const handleServiceChange = (service) => {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
}
})
);
};
const handleRegionChange = (region) => {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
}
})
);
};
const handleProfileNameChange = (profileName) => {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Access Key ID</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={awsv4Auth.accessKeyId || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAccessKeyIdChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Secret Access Key</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={awsv4Auth.secretAccessKey || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleSecretAccessKeyChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Session Token</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={awsv4Auth.sessionToken || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleSessionTokenChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Service</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={awsv4Auth.service || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleServiceChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Region</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={awsv4Auth.region || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleRegionChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Profile Name</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={awsv4Auth.profileName || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleProfileNameChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default AwsV4Auth;

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import AuthMode from './AuthMode'; import AuthMode from './AuthMode';
import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth'; import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth'; import BasicAuth from './BasicAuth';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -10,6 +11,9 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => { const getAuthView = () => {
switch (authMode) { switch (authMode) {
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} />;
}
case 'basic': { case 'basic': {
return <BasicAuth collection={collection} item={item} />; return <BasicAuth collection={collection} item={item} />;
} }

View File

@ -349,6 +349,10 @@ export const collectionsSlice = createSlice({
item.draft.request.auth = item.draft.request.auth || {}; item.draft.request.auth = item.draft.request.auth || {};
switch (action.payload.mode) { switch (action.payload.mode) {
case 'awsv4':
item.draft.request.auth.mode = 'awsv4';
item.draft.request.auth.awsv4 = action.payload.content;
break;
case 'bearer': case 'bearer':
item.draft.request.auth.mode = 'bearer'; item.draft.request.auth.mode = 'bearer';
item.draft.request.auth.bearer = action.payload.content; item.draft.request.auth.bearer = action.payload.content;

View File

@ -473,6 +473,10 @@ export const humanizeRequestBodyMode = (mode) => {
export const humanizeRequestAuthMode = (mode) => { export const humanizeRequestAuthMode = (mode) => {
let label = 'No Auth'; let label = 'No Auth';
switch (mode) { switch (mode) {
case 'awsv4': {
label = 'AWS Sig V4';
break;
}
case 'basic': { case 'basic': {
label = 'Basic Auth'; label = 'Basic Auth';
break; break;

View File

@ -14,10 +14,12 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/credential-providers": "^3.425.0",
"@usebruno/js": "0.6.0", "@usebruno/js": "0.6.0",
"@usebruno/lang": "0.5.0", "@usebruno/lang": "0.5.0",
"@usebruno/schema": "0.5.0", "@usebruno/schema": "0.5.0",
"about-window": "^1.15.2", "about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",

View File

@ -0,0 +1,49 @@
const { fromIni } = require('@aws-sdk/credential-providers');
const { aws4Interceptor } = require('aws4-axios');
function isStrPresent(str) {
return str && str !== '' && str !== 'undefined';
}
function addAwsV4Interceptor(axiosInstance, request) {
if (!request.awsv4config) {
console.warn('No Auth Config found!');
return;
}
const awsv4 = request.awsv4config;
if (!isStrPresent(awsv4.profileName) && (!isStrPresent(awsv4.accessKeyId) || !isStrPresent(awsv4.secretAccessKey))) {
console.warn('Required Auth Fields are not present');
return;
}
let credentials = {
accessKeyId: awsv4.accessKeyId,
secretAccessKey: awsv4.secretAccessKey,
sessionToken: awsv4.sessionToken
};
if (isStrPresent(awsv4.profileName)) {
try {
credentials = fromIni({
profile: awsv4.profileName
});
} catch {
console.error('Failed to fetch credentials from AWS profile.');
}
}
const interceptor = aws4Interceptor({
options: {
region: awsv4.region,
service: awsv4.service
},
credentials
});
axiosInstance.interceptors.request.use(interceptor);
console.log('Added AWS V4 interceptor to axios.');
}
module.exports = {
addAwsV4Interceptor
};

View File

@ -17,6 +17,7 @@ const { getPreferences } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env'); const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config'); const { getBrunoConfig } = require('../../store/bruno-config');
const { makeAxiosInstance } = require('./axios-instance'); const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor } = require('./awsv4auth-helper');
// override the default escape function to prevent escaping // override the default escape function to prevent escaping
Mustache.escape = function (value) { Mustache.escape = function (value) {
@ -246,6 +247,11 @@ const registerNetworkIpc = (mainWindow) => {
const axiosInstance = makeAxiosInstance(); const axiosInstance = makeAxiosInstance();
if (request.awsv4config) {
addAwsV4Interceptor(axiosInstance, request);
delete request.awsv4config;
}
/** @type {import('axios').AxiosResponse} */ /** @type {import('axios').AxiosResponse} */
const response = await axiosInstance(request); const response = await axiosInstance(request);

View File

@ -121,6 +121,16 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
delete request.auth; delete request.auth;
} }
// interpolate vars for aws sigv4 auth
if (request.awsv4config) {
request.awsv4config.accessKeyId = interpolate(request.awsv4config.accessKeyId) || '';
request.awsv4config.secretAccessKey = interpolate(request.awsv4config.secretAccessKey) || '';
request.awsv4config.sessionToken = interpolate(request.awsv4config.sessionToken) || '';
request.awsv4config.service = interpolate(request.awsv4config.service) || '';
request.awsv4config.region = interpolate(request.awsv4config.region) || '';
request.awsv4config.profileName = interpolate(request.awsv4config.profileName) || '';
}
return request; return request;
}; };

View File

@ -21,15 +21,26 @@ const prepareRequest = (request) => {
// Authentication // Authentication
if (request.auth) { if (request.auth) {
if (request.auth.mode === 'basic') { switch (request.auth.mode) {
case 'awsv4':
axiosRequest.awsv4config = {
accessKeyId: get(request, 'auth.awsv4.accessKeyId'),
secretAccessKey: get(request, 'auth.awsv4.secretAccessKey'),
sessionToken: get(request, 'auth.awsv4.sessionToken'),
service: get(request, 'auth.awsv4.service'),
region: get(request, 'auth.awsv4.region'),
profileName: get(request, 'auth.awsv4.profileName')
};
break;
case 'basic':
axiosRequest.auth = { axiosRequest.auth = {
username: get(request, 'auth.basic.username'), username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password') password: get(request, 'auth.basic.password')
}; };
} break;
case 'bearer':
if (request.auth.mode === 'bearer') {
axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
break;
} }
} }

View File

@ -23,7 +23,7 @@ const { outdentString } = require('../../v1/src/utils');
*/ */
const grammar = ohm.grammar(`Bru { const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)* BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authbasic | authbearer auths = authawsv4 | authbasic | authbearer
bodies = bodyjson | bodytext | bodyxml | bodygraphql | bodygraphqlvars | bodyforms | body bodies = bodyjson | bodytext | bodyxml | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart bodyforms = bodyformurlencoded | bodymultipart
@ -76,6 +76,7 @@ const grammar = ohm.grammar(`Bru {
varsres = "vars:post-response" dictionary varsres = "vars:post-response" dictionary
assert = "assert" assertdictionary assert = "assert" assertdictionary
authawsv4 = "auth:awsv4" dictionary
authbasic = "auth:basic" dictionary authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary authbearer = "auth:bearer" dictionary
@ -294,6 +295,33 @@ const sem = grammar.createSemantics().addAttribute('ast', {
headers: mapPairListToKeyValPairs(dictionary.ast) headers: mapPairListToKeyValPairs(dictionary.ast)
}; };
}, },
authawsv4(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const accessKeyIdKey = _.find(auth, { name: 'accessKeyId' });
const secretAccessKeyKey = _.find(auth, { name: 'secretAccessKey' });
const sessionTokenKey = _.find(auth, { name: 'sessionToken' });
const serviceKey = _.find(auth, { name: 'service' });
const regionKey = _.find(auth, { name: 'region' });
const profileNameKey = _.find(auth, { name: 'profileName' });
const accessKeyId = accessKeyIdKey ? accessKeyIdKey.value : '';
const secretAccessKey = secretAccessKeyKey ? secretAccessKeyKey.value : '';
const sessionToken = sessionTokenKey ? sessionTokenKey.value : '';
const service = serviceKey ? serviceKey.value : '';
const region = regionKey ? regionKey.value : '';
const profileName = profileNameKey ? profileNameKey.value : '';
return {
auth: {
awsv4: {
accessKeyId,
secretAccessKey,
sessionToken,
service,
region,
profileName
}
}
};
},
authbasic(_1, dictionary) { authbasic(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false); const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const usernameKey = _.find(auth, { name: 'username' }); const usernameKey = _.find(auth, { name: 'username' });

View File

@ -87,6 +87,19 @@ const jsonToBru = (json) => {
bru += '\n}\n\n'; bru += '\n}\n\n';
} }
if (auth && auth.awsv4) {
bru += `auth:awsv4 {
${indentString(`accessKeyId: ${auth.awsv4.accessKeyId}`)}
${indentString(`secretAccessKey: ${auth.awsv4.secretAccessKey}`)}
${indentString(`sessionToken: ${auth.awsv4.sessionToken}`)}
${indentString(`service: ${auth.awsv4.service}`)}
${indentString(`region: ${auth.awsv4.region}`)}
${indentString(`profileName: ${auth.awsv4.profileName}`)}
}
`;
}
if (auth && auth.basic) { if (auth && auth.basic) {
bru += `auth:basic { bru += `auth:basic {
${indentString(`username: ${auth.basic.username}`)} ${indentString(`username: ${auth.basic.username}`)}

View File

@ -22,6 +22,15 @@ headers {
~transaction-id: {{transactionId}} ~transaction-id: {{transactionId}}
} }
auth:awsv4 {
accessKeyId: A12345678
secretAccessKey: thisisasecret
sessionToken: thisisafakesessiontoken
service: execute-api
region: us-east-1
profileName: test_profile
}
auth:basic { auth:basic {
username: john username: john
password: secret password: secret

View File

@ -45,6 +45,14 @@
} }
], ],
"auth": { "auth": {
"awsv4": {
"accessKeyId": "A12345678",
"secretAccessKey": "thisisasecret",
"sessionToken": "thisisafakesessiontoken",
"service": "execute-api",
"region": "us-east-1",
"profileName": "test_profile"
},
"basic": { "basic": {
"username": "john", "username": "john",
"password": "secret" "password": "secret"

View File

@ -69,6 +69,17 @@ const requestBodySchema = Yup.object({
.noUnknown(true) .noUnknown(true)
.strict(); .strict();
const authAwsV4Schema = Yup.object({
accessKeyId: Yup.string().nullable(),
secretAccessKey: Yup.string().nullable(),
sessionToken: Yup.string().nullable(),
service: Yup.string().nullable(),
region: Yup.string().nullable(),
profileName: Yup.string().nullable()
})
.noUnknown(true)
.strict();
const authBasicSchema = Yup.object({ const authBasicSchema = Yup.object({
username: Yup.string().nullable(), username: Yup.string().nullable(),
password: Yup.string().nullable() password: Yup.string().nullable()
@ -83,7 +94,8 @@ const authBearerSchema = Yup.object({
.strict(); .strict();
const authSchema = Yup.object({ const authSchema = Yup.object({
mode: Yup.string().oneOf(['none', 'basic', 'bearer']).required('mode is required'), mode: Yup.string().oneOf(['none', 'awsv4', 'basic', 'bearer']).required('mode is required'),
awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(), basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable() bearer: authBearerSchema.nullable()
}) })