Improved Feat/wsse auth (#3172)

* adding wsse auth logic

* adding wsse auth logic to electron

* adding wsse auth formatting

* Refactoring WSSE 'secret' to 'password'

* Incorporating PR feedback

* Removed unused packages from package.json

* Fixed issue caused when resolving merge conflicts and added new route to test wsse

* Removed deprecated package usages from bruno-cli

* Fixed tests

---------

Co-authored-by: dwolter-emarsys <dylan.wolter@emarsys.com>
This commit is contained in:
Sanjai Kumar 2024-09-23 17:46:31 +05:30 committed by GitHub
parent bebb18fc99
commit 4d820af4e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 447 additions and 13 deletions

2
package-lock.json generated
View File

@ -18862,4 +18862,4 @@
}
}
}
}
}

View File

@ -52,6 +52,15 @@ const AuthMode = ({ collection }) => {
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
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,71 @@
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 { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = get(collection, 'root.request.auth.wsse', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleUserChange = (username) => {
dispatch(
updateCollectionAuth({
mode: 'wsse',
collectionUid: collection.uid,
content: {
username,
password: wsseAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateCollectionAuth({
mode: 'wsse',
collectionUid: collection.uid,
content: {
username: wsseAuth.username,
password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={wsseAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUserChange(val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default WsseAuth;

View File

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

View File

@ -30,7 +30,6 @@ const AuthMode = ({ item, collection }) => {
})
);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
@ -80,6 +79,15 @@ const AuthMode = ({ item, collection }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -0,0 +1,17 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,76 @@
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 { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleUserChange = (username) => {
dispatch(
updateAuth({
mode: 'wsse',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username,
password: wsseAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateAuth({
mode: 'wsse',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: wsseAuth.username,
password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={wsseAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default WsseAuth;

View File

@ -5,6 +5,7 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections/index';
@ -33,6 +34,9 @@ const Auth = ({ item, collection }) => {
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
}

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 'wsse':
item.draft.request.auth.mode = 'wsse';
item.draft.request.auth.wsse = action.payload.content;
break;
case 'apikey':
item.draft.request.auth.mode = 'apikey';
item.draft.request.auth.apikey = action.payload.content;
@ -1141,6 +1145,9 @@ export const collectionsSlice = createSlice({
case 'oauth2':
set(collection, 'root.request.auth.oauth2', action.payload.content);
break;
case 'wsse':
set(collection, 'root.request.auth.wsse', action.payload.content);
break;
case 'apikey':
set(collection, 'root.request.auth.apikey', action.payload.content);
break;

View File

@ -379,7 +379,12 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
placement: get(si.request, 'auth.apikey.placement', 'header')
};
break;
case 'wsse':
di.request.auth.wsse = {
username: get(si.request, 'auth.wsse.username', ''),
password: get(si.request, 'auth.wsse.password', '')
};
break;
default:
break;
}
@ -669,6 +674,10 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'OAuth 2.0';
break;
}
case 'wsse': {
label = 'WSSE Auth';
break;
}
case 'apikey': {
label = 'API Key';
break;

View File

@ -2,6 +2,7 @@ const { get, each, filter } = require('lodash');
const fs = require('fs');
var JSONbig = require('json-bigint');
const decomment = require('decomment');
const crypto = require('node:crypto');
const prepareRequest = (request, collectionRoot) => {
const headers = {};
@ -69,6 +70,24 @@ const prepareRequest = (request, collectionRoot) => {
if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
}
if (request.auth.mode === 'wsse') {
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
}
}
request.body = request.body || {};

View File

@ -215,6 +215,12 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.digestConfig.password = _interpolate(request.digestConfig.password) || '';
}
// interpolate vars for wsse auth
if (request.wsse) {
request.wsse.username = _interpolate(request.wsse.username) || '';
request.wsse.password = _interpolate(request.wsse.password) || '';
}
return request;
};

View File

@ -4,6 +4,7 @@ const decomment = require('decomment');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
const crypto = require('node:crypto');
const { getTreePathFromCollectionToItem } = require('../../utils/collection');
const { buildFormUrlEncodedPayload } = require('../../utils/common');
@ -218,6 +219,23 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(collectionAuth, 'digest.password')
};
break;
case 'wsse':
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
break;
case 'apikey':
const apiKeyAuth = get(collectionAuth, 'apikey');
if (apiKeyAuth.placement === 'header') {
@ -295,6 +313,23 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
break;
}
break;
case 'wsse':
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
break;
case 'apikey':
const apiKeyAuth = get(request, 'auth.apikey');
if (apiKeyAuth.placement === 'header') {

View File

@ -43,7 +43,6 @@ class BrunoRequest {
getMethod() {
return this.req.method;
}
getAuthMode() {
if (this.req?.oauth2) {
return 'oauth2';
@ -55,6 +54,8 @@ class BrunoRequest {
return 'awsv4';
} else if (this.req?.digestConfig) {
return 'digest';
} else if (this.headers?.['X-WSSE'] || this.req?.auth?.username) {
return 'wsse';
} else {
return 'none';
}

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 | authapikey
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authwsse | 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
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary
body = "body" st* "{" nl* textblock tagend
@ -484,6 +485,23 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authwsse(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const userKey = _.find(auth, { name: 'username' });
const secretKey = _.find(auth, { name: 'password' });
const username = userKey ? userKey.value : '';
const password = secretKey ? secretKey.value : '';
return {
auth: {
wsse: {
username,
password
}
}
};
},
authapikey(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);

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 | authapikey
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authwsse | 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
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary
script = scriptreq | scriptres
@ -294,6 +295,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authwsse(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const userKey = _.find(auth, { name: 'username' });
const secretKey = _.find(auth, { name: 'password' });
const username = userKey ? userKey.value : '';
const password = secretKey ? secretKey.value : '';
return {
auth: {
wsse: {
username,
password
}
}
}
},
authapikey(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);

View File

@ -136,6 +136,15 @@ ${indentString(`username: ${auth?.basic?.username || ''}`)}
${indentString(`password: ${auth?.basic?.password || ''}`)}
}
`;
}
if (auth && auth.wsse) {
bru += `auth:wsse {
${indentString(`username: ${auth?.wsse?.username || ''}`)}
${indentString(`password: ${auth?.wsse?.password || ''}`)}
}
`;
}

View File

@ -94,6 +94,15 @@ ${indentString(`username: ${auth.basic.username}`)}
${indentString(`password: ${auth.basic.password}`)}
}
`;
}
if (auth && auth.wsse) {
bru += `auth:wsse {
${indentString(`username: ${auth.wsse.username}`)}
${indentString(`password: ${auth.wsse.password}`)}
}
`;
}

View File

@ -17,6 +17,11 @@ auth:basic {
password: secret
}
auth:wsse {
username: john
password: secret
}
auth:bearer {
token: 123
}

View File

@ -31,6 +31,10 @@
"digest": {
"username": "john",
"password": "secret"
},
"wsse": {
"username": "john",
"password": "secret"
}
},
"vars": {

View File

@ -40,6 +40,11 @@ auth:basic {
password: secret
}
auth:wsse {
username: john
password: secret
}
auth:bearer {
token: 123
}

View File

@ -83,6 +83,10 @@
"scope": "read write",
"state": "807061d5f0be",
"pkce": false
},
"wsse": {
"username": "john",
"password": "secret"
}
},
"body": {

View File

@ -106,6 +106,13 @@ const authBasicSchema = Yup.object({
.noUnknown(true)
.strict();
const authWsseSchema = Yup.object({
username: Yup.string().nullable(),
password: Yup.string().nullable()
})
.noUnknown(true)
.strict();
const authBearerSchema = Yup.object({
token: Yup.string().nullable()
})
@ -119,6 +126,14 @@ const authDigestSchema = Yup.object({
.noUnknown(true)
.strict();
const authApiKeySchema = Yup.object({
key: Yup.string().nullable(),
value: Yup.string().nullable(),
placement: Yup.string().oneOf(['header', 'queryparams']).nullable()
})
.noUnknown(true)
.strict();
const oauth2Schema = Yup.object({
grantType: Yup.string()
.oneOf(['client_credentials', 'password', 'authorization_code'])
@ -177,21 +192,16 @@ 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', 'apikey'])
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2', 'wsse', 'apikey'])
.required('mode is required'),
awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable(),
digest: authDigestSchema.nullable(),
oauth2: oauth2Schema.nullable(),
wsse: authWsseSchema.nullable(),
apikey: authApiKeySchema.nullable()
})
.noUnknown(true)

View File

@ -3,6 +3,7 @@ const router = express.Router();
const authBearer = require('./bearer');
const authBasic = require('./basic');
const authWsse = require('./wsse');
const authCookie = require('./cookie');
const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials');
const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode');
@ -13,6 +14,7 @@ router.use('/oauth2/authorization_code', authOAuth2AuthorizationCode);
router.use('/oauth2/client_credentials', authOAuth2ClientCredentials);
router.use('/bearer', authBearer);
router.use('/basic', authBasic);
router.use('/wsse', authWsse);
router.use('/cookie', authCookie);
module.exports = router;

View File

@ -0,0 +1,70 @@
'use strict';
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
function sha256(data) {
return crypto.createHash('sha256').update(data).digest('base64');
}
function validateWSSE(req, res, next) {
const wsseHeader = req.headers['x-wsse'];
if (!wsseHeader) {
return unauthorized(res, 'WSSE header is missing');
}
const regex = /UsernameToken Username="(.+?)", PasswordDigest="(.+?)", (?:Nonce|nonce)="(.+?)", Created="(.+?)"/;
const matches = wsseHeader.match(regex);
if (!matches) {
return unauthorized(res, 'Invalid WSSE header format');
}
const [_, username, passwordDigest, nonce, created] = matches;
const expectedPassword = 'bruno'; // Ideally store in a config or env variable
const expectedDigest = sha256(nonce + created + expectedPassword);
if (passwordDigest !== expectedDigest) {
return unauthorized(res, 'Invalid credentials');
}
next();
}
// Helper to respond with an unauthorized SOAP fault
function unauthorized(res, message) {
const faultResponse = `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:web="http://webservice/">
<soapenv:Header/>
<soapenv:Body>
<soapenv:Fault>
<faultcode>soapenv:Client</faultcode>
<faultstring>${message}</faultstring>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>
`;
res.status(401).set('Content-Type', 'text/xml');
res.send(faultResponse);
}
const responses = {
success: `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:web="http://webservice/">
<soapenv:Header/>
<soapenv:Body>
<web:response>
<web:result>Success</web:result>
</web:response>
</soapenv:Body>
</soapenv:Envelope>
`
};
router.post('/protected', validateWSSE, (req, res) => {
res.set('Content-Type', 'text/xml');
res.send(responses.success);
});
module.exports = router;