mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 22:18:33 +00:00
feat(auth): akamai auth integration (#8199)
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
@@ -66,6 +67,17 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'API Key',
|
||||
onClick: () => onModeChange('apikey')
|
||||
},
|
||||
{
|
||||
id: 'akamai-edgegrid',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Akamai EdgeGrid
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
ariaLabel: 'Akamai EdgeGrid (Beta)',
|
||||
onClick: () => onModeChange('akamai-edgegrid')
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
label: 'No Auth',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.single-line-editor-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
|
||||
&:focus-within {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
margin: 1rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
user-select: none;
|
||||
|
||||
.advanced-settings-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-hint {
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-info {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
z-index: 10;
|
||||
width: max-content;
|
||||
max-width: 15rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #374151;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .field-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
interface AkamaiEdgeGridAuthValues {
|
||||
accessToken?: string;
|
||||
clientToken?: string;
|
||||
clientSecret?: string;
|
||||
nonce?: string;
|
||||
timestamp?: string;
|
||||
baseURL?: string;
|
||||
headersToSign?: string;
|
||||
maxBodySize?: number | null;
|
||||
}
|
||||
|
||||
type EdgeGridField = keyof AkamaiEdgeGridAuthValues;
|
||||
|
||||
const toMaxBodySize = (value: string): number | null => {
|
||||
if (value === '' || value == null) return null;
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
interface AkamaiEdgeGridAuthProps {
|
||||
collection: any;
|
||||
}
|
||||
|
||||
const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [
|
||||
{ key: 'accessToken', label: 'Access Token' },
|
||||
{ key: 'clientToken', label: 'Client Token' },
|
||||
{ key: 'clientSecret', label: 'Client Secret', isSecret: true },
|
||||
{ key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' },
|
||||
{
|
||||
key: 'nonce',
|
||||
label: 'Nonce',
|
||||
tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: 'Timestamp',
|
||||
tooltip:
|
||||
'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'headersToSign',
|
||||
label: 'Headers to Sign',
|
||||
tooltip: 'Comma-separated list of headers to include in the signature.'
|
||||
},
|
||||
{
|
||||
key: 'maxBodySize',
|
||||
label: 'Max Body Size',
|
||||
tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.'
|
||||
}
|
||||
];
|
||||
|
||||
type EdgeGridFieldConfig = (typeof FIELDS)[number];
|
||||
|
||||
// Fields shown up front vs. those grouped under the "Advanced Settings" section
|
||||
const BASIC_FIELDS = FIELDS.slice(0, 3);
|
||||
const ADVANCED_FIELDS = FIELDS.slice(3);
|
||||
|
||||
const EdgeGridAuth: React.FC<AkamaiEdgeGridAuthProps> = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const edgeGridAuth: AkamaiEdgeGridAuthValues =
|
||||
(collection.draft?.root
|
||||
? get(collection, 'draft.root.request.auth.akamaiEdgegrid')
|
||||
: get(collection, 'root.request.auth.akamaiEdgegrid')) || {};
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive(
|
||||
edgeGridAuth?.clientSecret
|
||||
);
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
|
||||
|
||||
const handleFieldChange = (field: EdgeGridField, value: string) => {
|
||||
const content: AkamaiEdgeGridAuthValues = {
|
||||
accessToken: edgeGridAuth.accessToken || '',
|
||||
clientToken: edgeGridAuth.clientToken || '',
|
||||
clientSecret: edgeGridAuth.clientSecret || '',
|
||||
nonce: edgeGridAuth.nonce || '',
|
||||
timestamp: edgeGridAuth.timestamp || '',
|
||||
baseURL: edgeGridAuth.baseURL || '',
|
||||
headersToSign: edgeGridAuth.headersToSign || '',
|
||||
maxBodySize: edgeGridAuth.maxBodySize ?? null
|
||||
};
|
||||
|
||||
if (field === 'maxBodySize') {
|
||||
content.maxBodySize = toMaxBodySize(value);
|
||||
} else {
|
||||
(content as Record<string, unknown>)[field] = value || '';
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateCollectionAuth({
|
||||
mode: 'akamai-edgegrid',
|
||||
collectionUid: collection.uid,
|
||||
content
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => {
|
||||
const showWarning = isSecret && showClientSecretWarning;
|
||||
const rawValue = edgeGridAuth[key];
|
||||
const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue);
|
||||
return (
|
||||
<div key={key}>
|
||||
<label>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<span className="field-info">
|
||||
<IconInfoCircle size={16} />
|
||||
<span className="field-tooltip">{tooltip}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="single-line-editor-wrapper">
|
||||
<SingleLineEditor
|
||||
value={fieldValue}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val: string) => handleFieldChange(key, val)}
|
||||
collection={collection}
|
||||
isSecret={isSecret}
|
||||
isCompact
|
||||
/>
|
||||
{showWarning && (
|
||||
<SensitiveFieldWarning fieldName="edgegrid-client-secret" warningMessage={clientSecretWarningMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 w-full">
|
||||
{BASIC_FIELDS.map(renderField)}
|
||||
|
||||
<div className="advanced-settings-header">
|
||||
<span className="advanced-settings-icon">
|
||||
<IconAdjustmentsHorizontal size={16} />
|
||||
</span>
|
||||
<span>Advanced Settings</span>
|
||||
</div>
|
||||
|
||||
<>
|
||||
{ADVANCED_FIELDS.map(renderField)}
|
||||
</>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeGridAuth;
|
||||
@@ -8,6 +8,7 @@ import BasicAuth from './BasicAuth';
|
||||
import DigestAuth from './DigestAuth';
|
||||
import WsseAuth from './WsseAuth';
|
||||
import ApiKeyAuth from './ApiKeyAuth/';
|
||||
import EdgeGridAuth from './EdgeGridAuth';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OAuth2 from './OAuth2';
|
||||
@@ -50,6 +51,9 @@ const Auth = ({ collection }) => {
|
||||
case 'apikey': {
|
||||
return <ApiKeyAuth collection={collection} />;
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return <EdgeGridAuth collection={collection} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import React, { useMemo } 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 as _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 OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
|
||||
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
|
||||
import AuthMode from '../AuthMode';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
|
||||
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
|
||||
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
|
||||
import EdgeGridAuth from 'components/RequestPane/Auth/EdgeGridAuth';
|
||||
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
|
||||
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
|
||||
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
|
||||
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
|
||||
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import get from 'lodash/get';
|
||||
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import AuthMode from '../AuthMode';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -182,6 +183,19 @@ const Auth = ({ collection, folder }) => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return (
|
||||
<>
|
||||
<EdgeGridAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'none': {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
@@ -67,6 +68,17 @@ const AuthMode = ({ collection, folder }) => {
|
||||
label: 'API Key',
|
||||
onClick: () => onModeChange('apikey')
|
||||
},
|
||||
{
|
||||
id: 'akamai-edgegrid',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Akamai EdgeGrid
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
ariaLabel: 'Akamai EdgeGrid (Beta)',
|
||||
onClick: () => onModeChange('akamai-edgegrid')
|
||||
},
|
||||
{
|
||||
id: 'inherit',
|
||||
label: 'Inherit',
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
@@ -67,6 +68,17 @@ const AuthMode = ({ item, collection }) => {
|
||||
label: 'API Key',
|
||||
onClick: () => onModeChange('apikey')
|
||||
},
|
||||
{
|
||||
id: 'akamai-edgegrid',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Akamai EdgeGrid
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
ariaLabel: 'Akamai EdgeGrid (Beta)',
|
||||
onClick: () => onModeChange('akamai-edgegrid')
|
||||
},
|
||||
{
|
||||
id: 'inherit',
|
||||
label: 'Inherit',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.single-line-editor-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
|
||||
&:focus-within {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
margin: 1rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
user-select: none;
|
||||
|
||||
.advanced-settings-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-hint {
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-info {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
z-index: 10;
|
||||
width: max-content;
|
||||
max-width: 15rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #374151;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .field-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
interface AkamaiEdgeGridAuthValues {
|
||||
accessToken?: string;
|
||||
clientToken?: string;
|
||||
clientSecret?: string;
|
||||
nonce?: string;
|
||||
timestamp?: string;
|
||||
baseURL?: string;
|
||||
headersToSign?: string;
|
||||
maxBodySize?: number | null;
|
||||
}
|
||||
|
||||
type EdgeGridField = keyof AkamaiEdgeGridAuthValues;
|
||||
|
||||
// Coerce the Max Body Size editor string into the numeric value the model stores (empty/invalid -> null).
|
||||
const toMaxBodySize = (value: string): number | null => {
|
||||
if (value === '' || value == null) return null;
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
interface AkamaiEdgeGridAuthProps {
|
||||
item: any;
|
||||
collection: any;
|
||||
request: any;
|
||||
updateAuth: (payload: any) => any;
|
||||
save: () => void;
|
||||
}
|
||||
|
||||
const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [
|
||||
{ key: 'accessToken', label: 'Access Token' },
|
||||
{ key: 'clientToken', label: 'Client Token' },
|
||||
{ key: 'clientSecret', label: 'Client Secret', isSecret: true },
|
||||
{ key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' },
|
||||
{
|
||||
key: 'nonce',
|
||||
label: 'Nonce',
|
||||
tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: 'Timestamp',
|
||||
tooltip:
|
||||
'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'headersToSign',
|
||||
label: 'Headers to Sign',
|
||||
tooltip: 'Comma-separated list of headers to include in the signature.'
|
||||
},
|
||||
{
|
||||
key: 'maxBodySize',
|
||||
label: 'Max Body Size',
|
||||
tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.'
|
||||
}
|
||||
];
|
||||
|
||||
type EdgeGridFieldConfig = (typeof FIELDS)[number];
|
||||
|
||||
// Fields shown up front vs. those grouped under the "Advanced Settings" section
|
||||
const BASIC_FIELDS = FIELDS.slice(0, 3);
|
||||
const ADVANCED_FIELDS = FIELDS.slice(3);
|
||||
|
||||
const EdgeGridAuth: React.FC<AkamaiEdgeGridAuthProps> = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const edgeGridAuth: AkamaiEdgeGridAuthValues = get(request, 'auth.akamaiEdgegrid') || {};
|
||||
const requestUrl = get(request, 'url', '');
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive(
|
||||
edgeGridAuth?.clientSecret
|
||||
);
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: EdgeGridField, value: string) => {
|
||||
const content: AkamaiEdgeGridAuthValues = {
|
||||
accessToken: edgeGridAuth.accessToken || '',
|
||||
clientToken: edgeGridAuth.clientToken || '',
|
||||
clientSecret: edgeGridAuth.clientSecret || '',
|
||||
nonce: edgeGridAuth.nonce || '',
|
||||
timestamp: edgeGridAuth.timestamp || '',
|
||||
baseURL: edgeGridAuth.baseURL || '',
|
||||
headersToSign: edgeGridAuth.headersToSign || '',
|
||||
maxBodySize: edgeGridAuth.maxBodySize ?? null
|
||||
};
|
||||
|
||||
if (field === 'maxBodySize') {
|
||||
content.maxBodySize = toMaxBodySize(value);
|
||||
} else {
|
||||
(content as Record<string, unknown>)[field] = value || '';
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'akamai-edgegrid',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => {
|
||||
const showWarning = isSecret && showClientSecretWarning;
|
||||
const rawValue = key === 'baseURL' ? edgeGridAuth.baseURL || requestUrl : edgeGridAuth[key];
|
||||
const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue);
|
||||
return (
|
||||
<div key={key}>
|
||||
<label>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<span className="field-info">
|
||||
<IconInfoCircle size={16} />
|
||||
<span className="field-tooltip">{tooltip}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="single-line-editor-wrapper">
|
||||
<SingleLineEditor
|
||||
value={fieldValue}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val: string) => handleFieldChange(key, val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={isSecret}
|
||||
isCompact
|
||||
/>
|
||||
{showWarning && (
|
||||
<SensitiveFieldWarning fieldName="edgegrid-client-secret" warningMessage={clientSecretWarningMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 w-full">
|
||||
{BASIC_FIELDS.map(renderField)}
|
||||
|
||||
<div className="advanced-settings-header">
|
||||
<span className="advanced-settings-icon">
|
||||
<IconAdjustmentsHorizontal size={16} />
|
||||
</span>
|
||||
<span>Advanced Settings</span>
|
||||
</div>
|
||||
|
||||
<>
|
||||
{ADVANCED_FIELDS.map(renderField)}
|
||||
</>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeGridAuth;
|
||||
@@ -12,6 +12,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ApiKeyAuth from './ApiKeyAuth';
|
||||
import EdgeGridAuth from './EdgeGridAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import OAuth2 from './OAuth2/index';
|
||||
@@ -68,6 +69,9 @@ const Auth = ({ item, collection }) => {
|
||||
case 'apikey': {
|
||||
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return <EdgeGridAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1111,6 +1111,10 @@ export const collectionsSlice = createSlice({
|
||||
item.draft.request.auth.mode = 'apikey';
|
||||
item.draft.request.auth.apikey = action.payload.content;
|
||||
break;
|
||||
case 'akamai-edgegrid':
|
||||
item.draft.request.auth.mode = 'akamai-edgegrid';
|
||||
item.draft.request.auth.akamaiEdgegrid = action.payload.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2233,6 +2237,9 @@ export const collectionsSlice = createSlice({
|
||||
case 'apikey':
|
||||
set(collection, 'draft.root.request.auth.apikey', action.payload.content);
|
||||
break;
|
||||
case 'akamai-edgegrid':
|
||||
set(collection, 'draft.root.request.auth.akamaiEdgegrid', action.payload.content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2565,6 +2572,9 @@ export const collectionsSlice = createSlice({
|
||||
case 'apikey':
|
||||
set(folder, 'draft.request.auth.apikey', action.payload.content);
|
||||
break;
|
||||
case 'akamai-edgegrid':
|
||||
set(folder, 'draft.request.auth.akamaiEdgegrid', action.payload.content);
|
||||
break;
|
||||
case 'awsv4':
|
||||
set(folder, 'draft.request.auth.awsv4', action.payload.content);
|
||||
break;
|
||||
|
||||
@@ -497,6 +497,18 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
password: get(si.request, 'auth.wsse.password', '')
|
||||
};
|
||||
break;
|
||||
case 'akamai-edgegrid':
|
||||
di.request.auth.akamaiEdgegrid = {
|
||||
accessToken: get(si.request, 'auth.akamaiEdgegrid.accessToken', ''),
|
||||
clientToken: get(si.request, 'auth.akamaiEdgegrid.clientToken', ''),
|
||||
clientSecret: get(si.request, 'auth.akamaiEdgegrid.clientSecret', ''),
|
||||
nonce: get(si.request, 'auth.akamaiEdgegrid.nonce', ''),
|
||||
timestamp: get(si.request, 'auth.akamaiEdgegrid.timestamp', ''),
|
||||
baseURL: get(si.request, 'auth.akamaiEdgegrid.baseURL', ''),
|
||||
headersToSign: get(si.request, 'auth.akamaiEdgegrid.headersToSign', ''),
|
||||
maxBodySize: get(si.request, 'auth.akamaiEdgegrid.maxBodySize', null)
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1014,6 +1026,10 @@ export const humanizeRequestAuthMode = (mode) => {
|
||||
label = 'API Key';
|
||||
break;
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
label = 'Akamai EdgeGrid';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return label;
|
||||
|
||||
@@ -94,6 +94,19 @@ const prepareRequest = async (item = {}, collection = {}) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'akamai-edgegrid') {
|
||||
axiosRequest.edgeGridConfig = {
|
||||
accessToken: get(collectionAuth, 'akamaiEdgegrid.accessToken'),
|
||||
clientToken: get(collectionAuth, 'akamaiEdgegrid.clientToken'),
|
||||
clientSecret: get(collectionAuth, 'akamaiEdgegrid.clientSecret'),
|
||||
nonce: get(collectionAuth, 'akamaiEdgegrid.nonce'),
|
||||
timestamp: get(collectionAuth, 'akamaiEdgegrid.timestamp'),
|
||||
baseURL: get(collectionAuth, 'akamaiEdgegrid.baseURL'),
|
||||
headersToSign: get(collectionAuth, 'akamaiEdgegrid.headersToSign'),
|
||||
maxBodySize: get(collectionAuth, 'akamaiEdgegrid.maxBodySize')
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'oauth2') {
|
||||
const grantType = get(collectionAuth, 'oauth2.grantType');
|
||||
|
||||
@@ -329,6 +342,19 @@ const prepareRequest = async (item = {}, collection = {}) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'akamai-edgegrid') {
|
||||
axiosRequest.edgeGridConfig = {
|
||||
accessToken: get(request, 'auth.akamaiEdgegrid.accessToken'),
|
||||
clientToken: get(request, 'auth.akamaiEdgegrid.clientToken'),
|
||||
clientSecret: get(request, 'auth.akamaiEdgegrid.clientSecret'),
|
||||
nonce: get(request, 'auth.akamaiEdgegrid.nonce'),
|
||||
timestamp: get(request, 'auth.akamaiEdgegrid.timestamp'),
|
||||
baseURL: get(request, 'auth.akamaiEdgegrid.baseURL'),
|
||||
headersToSign: get(request, 'auth.akamaiEdgegrid.headersToSign'),
|
||||
maxBodySize: get(request, 'auth.akamaiEdgegrid.maxBodySize')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
request.body = request.body || {};
|
||||
|
||||
@@ -18,7 +18,7 @@ const { parseDataFromResponse } = require('../utils/common');
|
||||
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
|
||||
const { createFormData } = require('../utils/form-data');
|
||||
const { NtlmClient } = require('axios-ntlm');
|
||||
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests');
|
||||
const { addDigestInterceptor, addEdgeGridInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests');
|
||||
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
|
||||
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
|
||||
const tokenStore = require('../store/tokenStore');
|
||||
@@ -692,6 +692,11 @@ const runSingleRequest = async function (
|
||||
delete request.digestConfig;
|
||||
}
|
||||
|
||||
if (request.edgeGridConfig) {
|
||||
addEdgeGridInterceptor(axiosInstance, request);
|
||||
delete request.edgeGridConfig;
|
||||
}
|
||||
|
||||
/** @type {import('axios').AxiosResponse} */
|
||||
response = await axiosInstance(request);
|
||||
|
||||
|
||||
@@ -420,6 +420,21 @@ export const brunoToPostman = (collection) => {
|
||||
]
|
||||
};
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return {
|
||||
type: 'edgegrid',
|
||||
edgegrid: [
|
||||
{ key: 'accessToken', value: itemAuth.akamaiEdgegrid?.accessToken || '', type: 'string' },
|
||||
{ key: 'clientToken', value: itemAuth.akamaiEdgegrid?.clientToken || '', type: 'string' },
|
||||
{ key: 'clientSecret', value: itemAuth.akamaiEdgegrid?.clientSecret || '', type: 'string' },
|
||||
{ key: 'baseURL', value: itemAuth.akamaiEdgegrid?.baseURL || '', type: 'string' },
|
||||
{ key: 'nonce', value: itemAuth.akamaiEdgegrid?.nonce || '', type: 'string' },
|
||||
{ key: 'timestamp', value: itemAuth.akamaiEdgegrid?.timestamp || '', type: 'string' },
|
||||
{ key: 'headersToSign', value: itemAuth.akamaiEdgegrid?.headersToSign || '', type: 'string' },
|
||||
{ key: 'maxBodySize', value: itemAuth.akamaiEdgegrid?.maxBodySize ?? '', type: 'string' }
|
||||
]
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: 'noauth'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import get from 'lodash/get';
|
||||
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
|
||||
import { transformExampleStatusInCollection } from '@usebruno/common';
|
||||
import each from 'lodash/each';
|
||||
import postmanTranslation from './postman-translations';
|
||||
import get from 'lodash/get';
|
||||
import { hydrateSeqInCollection, transformItemsInCollection, uuid, validateSchema } from '../common';
|
||||
import { invalidVariableCharacterRegex } from '../constants/index';
|
||||
import {
|
||||
extractPackagesFromScript,
|
||||
buildPackageReport,
|
||||
extractPackagesFromScript,
|
||||
TRANSLATOR_INJECTED_GLOBALS
|
||||
} from './postman-package-detector';
|
||||
import { invalidVariableCharacterRegex } from '../constants/index';
|
||||
import postmanTranslation from './postman-translations';
|
||||
|
||||
const AUTH_TYPES = Object.freeze({
|
||||
BASIC: 'basic',
|
||||
@@ -18,6 +18,7 @@ const AUTH_TYPES = Object.freeze({
|
||||
DIGEST: 'digest',
|
||||
OAUTH1: 'oauth1',
|
||||
OAUTH2: 'oauth2',
|
||||
EDGEGRID: 'edgegrid',
|
||||
NOAUTH: 'noauth',
|
||||
NONE: 'none'
|
||||
});
|
||||
@@ -91,6 +92,13 @@ const ensureString = (value, fallback = '') => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// EdgeGrid's maxBodySize is numeric in Bruno; coerce Postman's value (number or string) to a number or null.
|
||||
const ensureMaxBodySize = (value) => {
|
||||
if (value == null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
/**
|
||||
* Postman's schema allows headers as strings in the format "Key: Value".
|
||||
* This parses a single string header into an object.
|
||||
@@ -281,6 +289,19 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
|
||||
password: ensureString(authValues.password)
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.EDGEGRID:
|
||||
requestObject.auth.mode = 'akamai-edgegrid';
|
||||
requestObject.auth.akamaiEdgegrid = {
|
||||
accessToken: ensureString(authValues.accessToken),
|
||||
clientToken: ensureString(authValues.clientToken),
|
||||
clientSecret: ensureString(authValues.clientSecret),
|
||||
nonce: ensureString(authValues.nonce),
|
||||
timestamp: ensureString(authValues.timestamp),
|
||||
baseURL: ensureString(authValues.baseURL),
|
||||
headersToSign: ensureString(authValues.headersToSign),
|
||||
maxBodySize: ensureMaxBodySize(authValues.maxBodySize)
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.OAUTH1:
|
||||
requestObject.auth.oauth1 = {
|
||||
consumerKey: ensureString(authValues.consumerKey),
|
||||
|
||||
116
packages/bruno-converters/tests/postman/edgegrid-auth.spec.js
Normal file
116
packages/bruno-converters/tests/postman/edgegrid-auth.spec.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import postmanToBruno from '../../src/postman/postman-to-bruno';
|
||||
import { brunoToPostman } from '../../src/postman/bruno-to-postman';
|
||||
|
||||
const EDGEGRID = {
|
||||
accessToken: 'akab-access-token',
|
||||
clientToken: 'akab-client-token',
|
||||
clientSecret: 'secret==',
|
||||
nonce: 'my-nonce',
|
||||
timestamp: '20240101T00:00:00+0000',
|
||||
baseURL: 'https://akaa-x.luna.akamaiapis.net',
|
||||
headersToSign: 'X-Test1,X-Test2',
|
||||
maxBodySize: 2048
|
||||
};
|
||||
|
||||
const brunoCollectionWithEdgeGrid = () => ({
|
||||
name: 'EdgeGrid Collection',
|
||||
items: [
|
||||
{
|
||||
name: 'EdgeGrid Request',
|
||||
type: 'http-request',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://akaa-x.luna.akamaiapis.net/identity/v1',
|
||||
headers: [],
|
||||
auth: { mode: 'akamai-edgegrid', akamaiEdgegrid: { ...EDGEGRID } }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const findByKey = (arr, key) => arr.find((x) => x.key === key)?.value;
|
||||
|
||||
describe('EdgeGrid — Postman export (bruno → postman)', () => {
|
||||
it('produces a postman edgegrid auth block with all fields incl. maxBodySize', () => {
|
||||
const result = brunoToPostman(brunoCollectionWithEdgeGrid());
|
||||
const auth = result.item[0].request.auth;
|
||||
|
||||
expect(auth.type).toBe('edgegrid');
|
||||
expect(findByKey(auth.edgegrid, 'accessToken')).toBe(EDGEGRID.accessToken);
|
||||
expect(findByKey(auth.edgegrid, 'clientToken')).toBe(EDGEGRID.clientToken);
|
||||
expect(findByKey(auth.edgegrid, 'clientSecret')).toBe(EDGEGRID.clientSecret);
|
||||
expect(findByKey(auth.edgegrid, 'baseURL')).toBe(EDGEGRID.baseURL);
|
||||
expect(findByKey(auth.edgegrid, 'nonce')).toBe(EDGEGRID.nonce);
|
||||
expect(findByKey(auth.edgegrid, 'timestamp')).toBe(EDGEGRID.timestamp);
|
||||
expect(findByKey(auth.edgegrid, 'headersToSign')).toBe(EDGEGRID.headersToSign);
|
||||
expect(findByKey(auth.edgegrid, 'maxBodySize')).toBe(EDGEGRID.maxBodySize);
|
||||
});
|
||||
|
||||
it('handles empty optional fields without crashing', () => {
|
||||
const collection = {
|
||||
name: 'c',
|
||||
items: [
|
||||
{
|
||||
name: 'r',
|
||||
type: 'http-request',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://x.luna.akamaiapis.net/',
|
||||
headers: [],
|
||||
auth: { mode: 'akamai-edgegrid', akamaiEdgegrid: { accessToken: 'at', clientToken: 'ct', clientSecret: 'cs' } }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = brunoToPostman(collection);
|
||||
const auth = result.item[0].request.auth;
|
||||
expect(auth.type).toBe('edgegrid');
|
||||
expect(findByKey(auth.edgegrid, 'baseURL')).toBe('');
|
||||
expect(findByKey(auth.edgegrid, 'maxBodySize')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EdgeGrid — Postman import (postman → bruno)', () => {
|
||||
it('imports edgegrid auth at request level with all credentials', async () => {
|
||||
const postmanCollection = {
|
||||
info: { name: 'C', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' },
|
||||
item: [
|
||||
{
|
||||
name: 'EdgeGrid Request',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://akaa-x.luna.akamaiapis.net/identity/v1',
|
||||
auth: {
|
||||
type: 'edgegrid',
|
||||
edgegrid: [
|
||||
{ key: 'accessToken', value: EDGEGRID.accessToken },
|
||||
{ key: 'clientToken', value: EDGEGRID.clientToken },
|
||||
{ key: 'clientSecret', value: EDGEGRID.clientSecret },
|
||||
{ key: 'baseURL', value: EDGEGRID.baseURL },
|
||||
{ key: 'nonce', value: EDGEGRID.nonce },
|
||||
{ key: 'timestamp', value: EDGEGRID.timestamp },
|
||||
{ key: 'headersToSign', value: EDGEGRID.headersToSign },
|
||||
{ key: 'maxBodySize', value: EDGEGRID.maxBodySize }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
const { collection } = await postmanToBruno(postmanCollection);
|
||||
const auth = collection.items[0].request.auth;
|
||||
expect(auth.mode).toBe('akamai-edgegrid');
|
||||
expect(auth.akamaiEdgegrid).toEqual(EDGEGRID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EdgeGrid — Postman round-trip (bruno → postman → bruno)', () => {
|
||||
it('preserves all 8 fields including maxBodySize', async () => {
|
||||
const postman = brunoToPostman(brunoCollectionWithEdgeGrid());
|
||||
const { collection } = await postmanToBruno(postman);
|
||||
const auth = collection.items[0].request.auth;
|
||||
expect(auth.mode).toBe('akamai-edgegrid');
|
||||
expect(auth.akamaiEdgegrid).toEqual(EDGEGRID);
|
||||
});
|
||||
});
|
||||
@@ -690,4 +690,66 @@ describe('processAuth', () => {
|
||||
processAuth(auth, requestObject);
|
||||
expect(requestObject.auth.oauth1.placement).toBe('query');
|
||||
});
|
||||
|
||||
it('should handle edgegrid auth (Postman v2.1 array form)', () => {
|
||||
const auth = {
|
||||
type: 'edgegrid',
|
||||
edgegrid: [
|
||||
{ key: 'accessToken', value: 'akab-access-token', type: 'string' },
|
||||
{ key: 'clientToken', value: 'akab-client-token', type: 'string' },
|
||||
{ key: 'clientSecret', value: 'secret==', type: 'string' },
|
||||
{ key: 'baseURL', value: 'https://akaa-x.luna.akamaiapis.net', type: 'string' },
|
||||
{ key: 'nonce', value: 'my-nonce', type: 'string' },
|
||||
{ key: 'timestamp', value: '20240101T00:00:00+0000', type: 'string' },
|
||||
{ key: 'headersToSign', value: 'X-Test1,X-Test2', type: 'string' },
|
||||
{ key: 'maxBodySize', value: '2048', type: 'string' }
|
||||
]
|
||||
};
|
||||
processAuth(auth, requestObject);
|
||||
expect(requestObject.auth.mode).toBe('akamai-edgegrid');
|
||||
expect(requestObject.auth.akamaiEdgegrid).toEqual({
|
||||
accessToken: 'akab-access-token',
|
||||
clientToken: 'akab-client-token',
|
||||
clientSecret: 'secret==',
|
||||
nonce: 'my-nonce',
|
||||
timestamp: '20240101T00:00:00+0000',
|
||||
baseURL: 'https://akaa-x.luna.akamaiapis.net',
|
||||
headersToSign: 'X-Test1,X-Test2',
|
||||
maxBodySize: 2048
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle edgegrid auth (object form)', () => {
|
||||
const auth = {
|
||||
type: 'edgegrid',
|
||||
edgegrid: { accessToken: 'at', clientToken: 'ct', clientSecret: 'cs' }
|
||||
};
|
||||
processAuth(auth, requestObject);
|
||||
expect(requestObject.auth.mode).toBe('akamai-edgegrid');
|
||||
expect(requestObject.auth.akamaiEdgegrid).toEqual({
|
||||
accessToken: 'at',
|
||||
clientToken: 'ct',
|
||||
clientSecret: 'cs',
|
||||
nonce: '',
|
||||
timestamp: '',
|
||||
baseURL: '',
|
||||
headersToSign: '',
|
||||
maxBodySize: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle edgegrid auth with missing edgegrid key', () => {
|
||||
processAuth({ type: 'edgegrid' }, requestObject);
|
||||
expect(requestObject.auth.mode).toBe('akamai-edgegrid');
|
||||
expect(requestObject.auth.akamaiEdgegrid).toEqual({
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
clientSecret: '',
|
||||
nonce: '',
|
||||
timestamp: '',
|
||||
baseURL: '',
|
||||
headersToSign: '',
|
||||
maxBodySize: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ const { encodeUrl, hasExplicitScheme } = require('@usebruno/common').utils;
|
||||
const { extractPromptVariables } = require('@usebruno/common').utils;
|
||||
const { interpolateString } = require('./interpolate-string');
|
||||
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
|
||||
const { addDigestInterceptor } = require('@usebruno/requests');
|
||||
const { addDigestInterceptor, addEdgeGridInterceptor } = require('@usebruno/requests');
|
||||
const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
|
||||
const { prepareRequest } = require('./prepare-request');
|
||||
const interpolateVars = require('./interpolate-vars');
|
||||
@@ -315,6 +315,11 @@ const configureRequest = async (
|
||||
addDigestInterceptor(axiosInstance, request);
|
||||
}
|
||||
|
||||
if (request.edgeGridConfig) {
|
||||
addEdgeGridInterceptor(axiosInstance, request);
|
||||
delete request.edgeGridConfig;
|
||||
}
|
||||
|
||||
// Get timeout from request settings, fallback to global preference
|
||||
const resolvedSettings = resolveInheritedSettings(request.settings || {});
|
||||
request.timeout = resolvedSettings.timeout;
|
||||
|
||||
@@ -91,6 +91,18 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
|
||||
}
|
||||
break;
|
||||
case 'akamai-edgegrid':
|
||||
axiosRequest.edgeGridConfig = {
|
||||
accessToken: get(collectionAuth, 'akamaiEdgegrid.accessToken'),
|
||||
clientToken: get(collectionAuth, 'akamaiEdgegrid.clientToken'),
|
||||
clientSecret: get(collectionAuth, 'akamaiEdgegrid.clientSecret'),
|
||||
nonce: get(collectionAuth, 'akamaiEdgegrid.nonce'),
|
||||
timestamp: get(collectionAuth, 'akamaiEdgegrid.timestamp'),
|
||||
baseURL: get(collectionAuth, 'akamaiEdgegrid.baseURL'),
|
||||
headersToSign: get(collectionAuth, 'akamaiEdgegrid.headersToSign'),
|
||||
maxBodySize: get(collectionAuth, 'akamaiEdgegrid.maxBodySize')
|
||||
};
|
||||
break;
|
||||
case 'oauth2':
|
||||
const grantType = get(collectionAuth, 'oauth2.grantType');
|
||||
switch (grantType) {
|
||||
@@ -345,6 +357,18 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
|
||||
}
|
||||
break;
|
||||
case 'akamai-edgegrid':
|
||||
axiosRequest.edgeGridConfig = {
|
||||
accessToken: get(request, 'auth.akamaiEdgegrid.accessToken'),
|
||||
clientToken: get(request, 'auth.akamaiEdgegrid.clientToken'),
|
||||
clientSecret: get(request, 'auth.akamaiEdgegrid.clientSecret'),
|
||||
nonce: get(request, 'auth.akamaiEdgegrid.nonce'),
|
||||
timestamp: get(request, 'auth.akamaiEdgegrid.timestamp'),
|
||||
baseURL: get(request, 'auth.akamaiEdgegrid.baseURL'),
|
||||
headersToSign: get(request, 'auth.akamaiEdgegrid.headersToSign'),
|
||||
maxBodySize: get(request, 'auth.akamaiEdgegrid.maxBodySize')
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,20 @@ import type { Auth as BrunoAuth, AuthOauth1 as BrunoAuthOauth1 } from '@usebruno
|
||||
import { isString } from '../../../utils';
|
||||
import { toOpenCollectionOAuth2, toBrunoOAuth2 } from './auth-oauth2';
|
||||
|
||||
// EdgeGrid is a Bruno-specific auth mode that is not part of the OpenCollection spec,
|
||||
// so its shape is defined locally and serialized using the same field names as Bruno.
|
||||
interface AkamaiEdgeGridAuthValues {
|
||||
type: 'akamai-edgegrid';
|
||||
accessToken?: string;
|
||||
clientToken?: string;
|
||||
clientSecret?: string;
|
||||
nonce?: string;
|
||||
timestamp?: string;
|
||||
baseURL?: string;
|
||||
headersToSign?: string;
|
||||
maxBodySize?: number;
|
||||
}
|
||||
|
||||
const buildAwsV4Auth = (config?: BrunoAuth['awsv4']): AuthAwsV4 => {
|
||||
const auth: AuthAwsV4 = { type: 'awsv4' };
|
||||
|
||||
@@ -116,6 +130,25 @@ const buildApiKeyAuth = (config?: BrunoAuth['apikey']): AuthApiKey => {
|
||||
return auth;
|
||||
};
|
||||
|
||||
const buildEdgeGridAuth = (config?: BrunoAuth['akamaiEdgegrid']): AkamaiEdgeGridAuthValues => {
|
||||
const auth: AkamaiEdgeGridAuthValues = { type: 'akamai-edgegrid' };
|
||||
|
||||
if (!config) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (isString(config.accessToken)) auth.accessToken = config.accessToken;
|
||||
if (isString(config.clientToken)) auth.clientToken = config.clientToken;
|
||||
if (isString(config.clientSecret)) auth.clientSecret = config.clientSecret;
|
||||
if (isString(config.nonce)) auth.nonce = config.nonce;
|
||||
if (isString(config.timestamp)) auth.timestamp = config.timestamp;
|
||||
if (isString(config.baseURL)) auth.baseURL = config.baseURL;
|
||||
if (isString(config.headersToSign)) auth.headersToSign = config.headersToSign;
|
||||
if (typeof config.maxBodySize === 'number') auth.maxBodySize = config.maxBodySize;
|
||||
|
||||
return auth;
|
||||
};
|
||||
|
||||
const buildOAuth1Auth = (config?: BrunoAuth['oauth1']): AuthOAuth1 => {
|
||||
const auth: AuthOAuth1 = { type: 'oauth1' };
|
||||
|
||||
@@ -173,6 +206,8 @@ export const toOpenCollectionAuth = (auth?: BrunoAuth | null): Auth | undefined
|
||||
return buildOAuth1Auth(auth.oauth1);
|
||||
case 'oauth2':
|
||||
return toOpenCollectionOAuth2(auth.oauth2);
|
||||
case 'akamai-edgegrid':
|
||||
return buildEdgeGridAuth(auth.akamaiEdgegrid) as unknown as Auth;
|
||||
default:
|
||||
console.warn(`toOpenCollectionAuth failed: Unsupported auth mode "${auth.mode}".`);
|
||||
return undefined;
|
||||
@@ -189,7 +224,8 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null =>
|
||||
ntlm: null,
|
||||
oauth2: null,
|
||||
wsse: null,
|
||||
apikey: null
|
||||
apikey: null,
|
||||
akamaiEdgegrid: null
|
||||
};
|
||||
|
||||
if (!auth) {
|
||||
@@ -201,6 +237,22 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null =>
|
||||
return brunoAuth;
|
||||
}
|
||||
|
||||
if ((auth as unknown as AkamaiEdgeGridAuthValues).type === 'akamai-edgegrid') {
|
||||
const edgegrid = auth as unknown as AkamaiEdgeGridAuthValues;
|
||||
brunoAuth.mode = 'akamai-edgegrid';
|
||||
brunoAuth.akamaiEdgegrid = {
|
||||
accessToken: edgegrid.accessToken || '',
|
||||
clientToken: edgegrid.clientToken || '',
|
||||
clientSecret: edgegrid.clientSecret || '',
|
||||
nonce: edgegrid.nonce || '',
|
||||
timestamp: edgegrid.timestamp || '',
|
||||
baseURL: edgegrid.baseURL || '',
|
||||
headersToSign: edgegrid.headersToSign || '',
|
||||
maxBodySize: typeof edgegrid.maxBodySize === 'number' ? edgegrid.maxBodySize : null
|
||||
};
|
||||
return brunoAuth;
|
||||
}
|
||||
|
||||
switch (auth.type) {
|
||||
case 'awsv4':
|
||||
brunoAuth.mode = 'awsv4';
|
||||
|
||||
@@ -35,7 +35,7 @@ const ANNOTATIONS_KEY = Symbol('annotations');
|
||||
*/
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | app | settings | docs | example)*
|
||||
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs
|
||||
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs
|
||||
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws
|
||||
bodyforms = bodyformurlencoded | bodymultipart | bodyfile
|
||||
params = paramspath | paramsquery
|
||||
@@ -147,6 +147,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
authOAuth2 = "auth:oauth2" dictionary
|
||||
authwsse = "auth:wsse" dictionary
|
||||
authapikey = "auth:apikey" dictionary
|
||||
authedgegrid = "auth:akamai-edgegrid" dictionary
|
||||
|
||||
oauth2AuthReqHeaders = "auth:oauth2:additional_params:auth_req:headers" dictionary
|
||||
oauth2AuthReqQueryParams = "auth:oauth2:additional_params:auth_req:queryparams" dictionary
|
||||
@@ -1005,6 +1006,39 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
};
|
||||
},
|
||||
authedgegrid(_1, dictionary) {
|
||||
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
|
||||
|
||||
const findValueByName = (name) => {
|
||||
const item = _.find(auth, { name });
|
||||
return item ? item.value : '';
|
||||
};
|
||||
|
||||
const accessToken = findValueByName('accessToken');
|
||||
const clientToken = findValueByName('clientToken');
|
||||
const clientSecret = findValueByName('clientSecret');
|
||||
const nonce = findValueByName('nonce');
|
||||
const timestamp = findValueByName('timestamp');
|
||||
const baseURL = findValueByName('baseURL');
|
||||
const headersToSign = findValueByName('headersToSign');
|
||||
const maxBodySizeRaw = findValueByName('maxBodySize');
|
||||
const maxBodySize = maxBodySizeRaw === '' || isNaN(Number(maxBodySizeRaw)) ? null : Number(maxBodySizeRaw);
|
||||
|
||||
return {
|
||||
auth: {
|
||||
akamaiEdgegrid: {
|
||||
accessToken,
|
||||
clientToken,
|
||||
clientSecret,
|
||||
nonce,
|
||||
timestamp,
|
||||
baseURL,
|
||||
headersToSign,
|
||||
maxBodySize
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
bodyformurlencoded(_1, dictionary) {
|
||||
return {
|
||||
body: {
|
||||
|
||||
@@ -8,7 +8,7 @@ const ANNOTATIONS_KEY = Symbol('annotations');
|
||||
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
|
||||
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs
|
||||
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs
|
||||
|
||||
// Oauth2 additional parameters
|
||||
authOauth2Configs = oauth2AuthReqConfig | oauth2AccessTokenReqConfig | oauth2RefreshTokenReqConfig
|
||||
@@ -93,7 +93,8 @@ const grammar = ohm.grammar(`Bru {
|
||||
authOAuth2 = "auth:oauth2" dictionary
|
||||
authwsse = "auth:wsse" dictionary
|
||||
authapikey = "auth:apikey" dictionary
|
||||
|
||||
authedgegrid = "auth:akamai-edgegrid" dictionary
|
||||
|
||||
script = scriptreq | scriptres
|
||||
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
|
||||
scriptres = "script:post-response" st* "{" nl* textblock tagend
|
||||
@@ -606,6 +607,39 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
};
|
||||
},
|
||||
authedgegrid(_1, dictionary) {
|
||||
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
|
||||
|
||||
const findValueByName = (name) => {
|
||||
const item = _.find(auth, { name });
|
||||
return item ? item.value : '';
|
||||
};
|
||||
|
||||
const accessToken = findValueByName('accessToken');
|
||||
const clientToken = findValueByName('clientToken');
|
||||
const clientSecret = findValueByName('clientSecret');
|
||||
const nonce = findValueByName('nonce');
|
||||
const timestamp = findValueByName('timestamp');
|
||||
const baseURL = findValueByName('baseURL');
|
||||
const headersToSign = findValueByName('headersToSign');
|
||||
const maxBodySizeRaw = findValueByName('maxBodySize');
|
||||
const maxBodySize = maxBodySizeRaw === '' || isNaN(Number(maxBodySizeRaw)) ? null : Number(maxBodySizeRaw);
|
||||
|
||||
return {
|
||||
auth: {
|
||||
akamaiEdgegrid: {
|
||||
accessToken,
|
||||
clientToken,
|
||||
clientSecret,
|
||||
nonce,
|
||||
timestamp,
|
||||
baseURL,
|
||||
headersToSign,
|
||||
maxBodySize
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
varsreq(_1, dictionary) {
|
||||
const vars = mapPairListToKeyValPairs(dictionary.ast, true, true);
|
||||
_.each(vars, (v) => {
|
||||
|
||||
@@ -487,6 +487,21 @@ ${indentString(`value: ${auth?.apikey?.value || ''}`)}
|
||||
${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (auth && auth.akamaiEdgegrid) {
|
||||
bru += `auth:akamai-edgegrid {
|
||||
${indentString(`accessToken: ${auth?.akamaiEdgegrid?.accessToken || ''}`)}
|
||||
${indentString(`clientToken: ${auth?.akamaiEdgegrid?.clientToken || ''}`)}
|
||||
${indentString(`clientSecret: ${auth?.akamaiEdgegrid?.clientSecret || ''}`)}
|
||||
${indentString(`nonce: ${auth?.akamaiEdgegrid?.nonce || ''}`)}
|
||||
${indentString(`timestamp: ${auth?.akamaiEdgegrid?.timestamp || ''}`)}
|
||||
${indentString(`baseURL: ${auth?.akamaiEdgegrid?.baseURL || ''}`)}
|
||||
${indentString(`headersToSign: ${auth?.akamaiEdgegrid?.headersToSign || ''}`)}
|
||||
${indentString(`maxBodySize: ${auth?.akamaiEdgegrid?.maxBodySize ?? ''}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,21 @@ ${indentString(`key: ${auth?.apikey?.key || ''}`)}
|
||||
${indentString(`value: ${auth?.apikey?.value || ''}`)}
|
||||
${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (auth && auth.akamaiEdgegrid) {
|
||||
bru += `auth:akamai-edgegrid {
|
||||
${indentString(`accessToken: ${auth?.akamaiEdgegrid?.accessToken || ''}`)}
|
||||
${indentString(`clientToken: ${auth?.akamaiEdgegrid?.clientToken || ''}`)}
|
||||
${indentString(`clientSecret: ${auth?.akamaiEdgegrid?.clientSecret || ''}`)}
|
||||
${indentString(`nonce: ${auth?.akamaiEdgegrid?.nonce || ''}`)}
|
||||
${indentString(`timestamp: ${auth?.akamaiEdgegrid?.timestamp || ''}`)}
|
||||
${indentString(`baseURL: ${auth?.akamaiEdgegrid?.baseURL || ''}`)}
|
||||
${indentString(`headersToSign: ${auth?.akamaiEdgegrid?.headersToSign || ''}`)}
|
||||
${indentString(`maxBodySize: ${auth?.akamaiEdgegrid?.maxBodySize ?? ''}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
214
packages/bruno-requests/src/auth/edgegrid-helper.js
Normal file
214
packages/bruno-requests/src/auth/edgegrid-helper.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
/**
|
||||
* Akamai EdgeGrid Authentication Helper
|
||||
* Implements the EG1-HMAC-SHA256 scheme exactly as Akamai's official client library
|
||||
* (https://github.com/akamai/AkamaiOPEN-edgegrid-node) so signatures validate against
|
||||
* the real Akamai gateway.
|
||||
* Spec: https://techdocs.akamai.com/developer/docs/authenticate-with-edgegrid
|
||||
*/
|
||||
|
||||
const MAX_BODY_SIZE_DEFAULT = 131072; // 128 KB
|
||||
|
||||
function isStrPresent(str) {
|
||||
return str && str.trim() !== '' && str.trim() !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UTC timestamp in EdgeGrid format: YYYYMMDDTHH:MM:SS+0000
|
||||
* (date part has no separators, time part keeps colons, fixed +0000 offset).
|
||||
* @returns {string}
|
||||
*/
|
||||
function makeEdgeGridTimestamp() {
|
||||
const [datePart, timePart] = new Date().toISOString().split('T');
|
||||
const date = datePart.replace(/-/g, '');
|
||||
const time = timePart.replace(/\.\d+Z$/, '');
|
||||
return `${date}T${time}+0000`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random nonce (UUID v4)
|
||||
* @returns {string}
|
||||
*/
|
||||
function makeEdgeGridNonce() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encoded HMAC-SHA256. Note the result is a base64 STRING; when used as the
|
||||
* signing key it is passed (as a string) as the key of the next HMAC — matching Akamai.
|
||||
* @param {string|Buffer} data
|
||||
* @param {string} key
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64HmacSha256(data, key) {
|
||||
return crypto.createHmac('sha256', key).update(data).digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encoded SHA256 hash.
|
||||
* @param {string|Buffer} data
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Sha256(data) {
|
||||
return crypto.createHash('sha256').update(data).digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonicalized headers segment from the comma-separated list of header NAMES
|
||||
* the user asked to sign, pulling the actual values off the outgoing request.
|
||||
* Format per header: `name(lowercase):value(trimmed, internal whitespace collapsed)`,
|
||||
* joined by a tab — matching Akamai's canonicalizeHeaders().
|
||||
* @param {string} headersToSign - comma-separated header names
|
||||
* @param {Object} requestHeaders - outgoing request headers ({ name: value })
|
||||
* @returns {string}
|
||||
*/
|
||||
function canonicalizeHeaders(headersToSign, requestHeaders = {}) {
|
||||
if (!isStrPresent(headersToSign)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// case-insensitive lookup of the actual request header values
|
||||
const lookup = {};
|
||||
Object.keys(requestHeaders || {}).forEach((name) => {
|
||||
lookup[name.toLowerCase()] = requestHeaders[name];
|
||||
});
|
||||
|
||||
return headersToSign
|
||||
.split(',')
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter((name) => name.length > 0)
|
||||
// Akamai only signs headers that are actually present on the request (config order preserved);
|
||||
// names not present are skipped, not emitted as empty values.
|
||||
.filter((name) => Object.prototype.hasOwnProperty.call(lookup, name))
|
||||
.map((name) => `${name}:${String(lookup[name]).trim().replace(/\s+/g, ' ')}`)
|
||||
.join('\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the request body content hash. Akamai hashes the body for POST requests only,
|
||||
* over the exact bytes that are sent on the wire (no re-serialization), truncated to
|
||||
* maxBodySize before hashing.
|
||||
* @param {Object} request - axios request config ({ method, data })
|
||||
* @param {number} maxBodySize
|
||||
* @returns {string} base64 SHA256 of the (possibly truncated) body, or '' when not applicable
|
||||
*/
|
||||
function makeContentHash(request, maxBodySize) {
|
||||
if (!request.method || request.method.toUpperCase() !== 'POST') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let body = request.data;
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
// Hash the bytes as sent — do NOT parse/re-stringify (that would change the payload).
|
||||
body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
if (body.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (body.length > maxBodySize) {
|
||||
body = body.substring(0, maxBodySize);
|
||||
}
|
||||
return base64Sha256(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an EdgeGrid request.
|
||||
* @param {Object} config - EdgeGrid configuration
|
||||
* @param {string} config.accessToken
|
||||
* @param {string} config.clientToken
|
||||
* @param {string} config.clientSecret
|
||||
* @param {string} [config.baseURL] - override host the request is signed against
|
||||
* @param {string} [config.nonce] - optional nonce override
|
||||
* @param {string} [config.timestamp] - optional timestamp override
|
||||
* @param {string} [config.headersToSign] - comma-separated header names to sign
|
||||
* @param {string|number} [config.maxBodySize=131072]
|
||||
* @param {Object} request - axios request config ({ method, url, headers, data })
|
||||
* @returns {string} Authorization header value
|
||||
*/
|
||||
export function signEdgeGridRequest(config, request) {
|
||||
const { accessToken, clientToken, clientSecret, baseURL, headersToSign } = config;
|
||||
const maxBodySize = config.maxBodySize ? parseInt(config.maxBodySize, 10) : MAX_BODY_SIZE_DEFAULT;
|
||||
|
||||
// Validate required fields
|
||||
if (!isStrPresent(accessToken)) {
|
||||
throw new Error('EdgeGrid: accessToken is required');
|
||||
}
|
||||
if (!isStrPresent(clientToken)) {
|
||||
throw new Error('EdgeGrid: clientToken is required');
|
||||
}
|
||||
if (!isStrPresent(clientSecret)) {
|
||||
throw new Error('EdgeGrid: clientSecret is required');
|
||||
}
|
||||
|
||||
// Generate or use provided nonce and timestamp
|
||||
const nonce = isStrPresent(config.nonce) ? config.nonce : makeEdgeGridNonce();
|
||||
const timestamp = isStrPresent(config.timestamp) ? config.timestamp : makeEdgeGridTimestamp();
|
||||
|
||||
// Determine the URL to sign — use baseURL's host/protocol if provided, otherwise the request URL.
|
||||
let urlToSign = request.url;
|
||||
if (isStrPresent(baseURL)) {
|
||||
const requestUrl = new URL(request.url);
|
||||
// A scheme-less baseURL like "localhost:6000" mis-parses ("localhost:" becomes the protocol
|
||||
// and the host is empty). If there's no "scheme://", borrow the request URL's scheme.
|
||||
const normalizedBaseURL = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseURL.trim())
|
||||
? baseURL.trim()
|
||||
: `${requestUrl.protocol}//${baseURL.trim()}`;
|
||||
const baseParsed = new URL(normalizedBaseURL);
|
||||
urlToSign = `${baseParsed.protocol}//${baseParsed.host}${requestUrl.pathname}${requestUrl.search}`;
|
||||
}
|
||||
const parsedUrl = new URL(urlToSign);
|
||||
|
||||
// Auth header (without the signature) — this exact string is also the LAST field of the
|
||||
// data-to-sign, per the EdgeGrid spec / official library.
|
||||
const authHeader
|
||||
= `EG1-HMAC-SHA256 client_token=${clientToken};access_token=${accessToken};timestamp=${timestamp};nonce=${nonce};`;
|
||||
|
||||
// data-to-sign: tab-joined, in the exact order Akamai expects.
|
||||
const dataToSign = [
|
||||
request.method.toUpperCase(),
|
||||
parsedUrl.protocol.replace(':', ''),
|
||||
parsedUrl.host,
|
||||
parsedUrl.pathname + parsedUrl.search,
|
||||
canonicalizeHeaders(headersToSign, request.headers),
|
||||
makeContentHash(request, maxBodySize),
|
||||
authHeader
|
||||
].join('\t');
|
||||
|
||||
// Signing key is the base64 STRING of HMAC(timestamp, clientSecret); the signature is then
|
||||
// HMAC(dataToSign, signingKey) — both base64-encoded.
|
||||
const signingKey = base64HmacSha256(timestamp, clientSecret);
|
||||
const signature = base64HmacSha256(dataToSign, signingKey);
|
||||
|
||||
return `${authHeader}signature=${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add EdgeGrid interceptor to axios instance
|
||||
* @param {Object} axiosInstance - Axios instance
|
||||
* @param {Object} request - Request object with edgeGridConfig
|
||||
*/
|
||||
export function addEdgeGridInterceptor(axiosInstance, request) {
|
||||
const { edgeGridConfig } = request;
|
||||
|
||||
if (!edgeGridConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add request interceptor to sign requests
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
try {
|
||||
const authHeader = signEdgeGridRequest(edgeGridConfig, config);
|
||||
config.headers['Authorization'] = authHeader;
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('EdgeGrid signing error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
197
packages/bruno-requests/src/auth/edgegrid-helper.spec.js
Normal file
197
packages/bruno-requests/src/auth/edgegrid-helper.spec.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const { signEdgeGridRequest } = require('./edgegrid-helper');
|
||||
|
||||
/**
|
||||
* These vectors are Akamai's OFFICIAL EdgeGrid test fixtures, taken from the reference
|
||||
* implementation (github.com/akamai/AkamaiOPEN-edgegrid-node — test/test.js & test/test_data.json).
|
||||
* Matching them byte-for-byte proves Bruno's signature is interoperable with the real
|
||||
* Akamai gateway. Credentials/nonce/timestamp are the published dummy values.
|
||||
*/
|
||||
const CREDS = {
|
||||
clientToken: 'akab-client-token-xxx-xxxxxxxxxxxxxxxx',
|
||||
accessToken: 'akab-access-token-xxx-xxxxxxxxxxxxxxxx',
|
||||
clientSecret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=',
|
||||
nonce: 'nonce-xx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||
timestamp: '20140321T19:34:21+0000'
|
||||
};
|
||||
const HOST = 'https://akaa-baseurl-xxxxxxxxxxx-xxxxxxxxxxxxx.luna.akamaiapis.net';
|
||||
const HEADERS_TO_SIGN = 'X-Test1,X-Test2,X-Test3';
|
||||
|
||||
const sig = (header) => header.split('signature=')[1];
|
||||
|
||||
describe('signEdgeGridRequest — Akamai official vectors (core)', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'simple GET',
|
||||
config: CREDS,
|
||||
request: { method: 'GET', url: `${HOST}/` },
|
||||
expected: 'tL+y4hxyHxgWVD30X3pWnGKHcPzmrIF+LThiAOhMxYU='
|
||||
},
|
||||
{
|
||||
name: 'GET with query string',
|
||||
config: CREDS,
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t1?p1=1&p2=2` },
|
||||
expected: 'hKDH1UlnQySSHjvIcZpDMbQHihTQ0XyVAKZaApabdeA='
|
||||
},
|
||||
{
|
||||
name: 'POST within body limit (body hashed)',
|
||||
config: CREDS,
|
||||
request: { method: 'POST', url: `${HOST}/testapi/v1/t3`, data: 'datadatadatadatadatadatadatadata' },
|
||||
expected: 'hXm4iCxtpN22m4cbZb4lVLW5rhX8Ca82vCFqXzSTPe4='
|
||||
},
|
||||
{
|
||||
name: 'POST with empty body (no content hash)',
|
||||
config: CREDS,
|
||||
request: { method: 'POST', url: `${HOST}/testapi/v1/t6`, data: '' },
|
||||
expected: '1gEDxeQGD5GovIkJJGcBaKnZ+VaPtrc4qBUHixjsPCQ='
|
||||
}
|
||||
];
|
||||
|
||||
test.each(cases)('$name', ({ config, request, expected }) => {
|
||||
expect(sig(signEdgeGridRequest(config, request))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signEdgeGridRequest — Akamai official vectors (headers_to_sign)', () => {
|
||||
const config = { ...CREDS, headersToSign: HEADERS_TO_SIGN, maxBodySize: 2048 };
|
||||
const cases = [
|
||||
{
|
||||
name: 'single signed header',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': 'test-simple-header' } },
|
||||
expected: '8F9AybcRw+PLxnvT+H0JRkjROrrUgsxJTnRXMzqvcwY='
|
||||
},
|
||||
{
|
||||
name: 'header value with surrounding/quoted spaces (trimmed + collapsed)',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': '" test-header-with-spaces "' } },
|
||||
expected: 'ucq2AbjCNtobHfCTuS38fdkl5UDdWHZhQX46fYR8CqI='
|
||||
},
|
||||
{
|
||||
name: 'header with leading + interior spaces (collapsed)',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': ' first-thing second-thing' } },
|
||||
expected: 'WtnneL539UadAAOJwnsXvPqT4Kt6z7HMgBEwAFpt3+c='
|
||||
},
|
||||
{
|
||||
name: 'headers signed in config order regardless of request order',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test2': 't2', 'X-Test1': 't1', 'X-Test3': 't3' } },
|
||||
expected: 'Wus73Nx8jOYM+kkBFF2q8D1EATRIMr0WLWwpLBgkBqY='
|
||||
},
|
||||
{
|
||||
name: 'headers not in the sign list are excluded',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: `${HOST}/testapi/v1/t5`,
|
||||
headers: { 'X-Test2': 't2', 'X-Test1': 't1', 'X-Test3': 't3', 'X-Extra': 'this won\'t be included' }
|
||||
},
|
||||
expected: 'Knd/jc0A5Ghhizjayr0AUUvl2MZjBpS3FDSzvtq4Ixc='
|
||||
}
|
||||
];
|
||||
|
||||
test.each(cases)('$name', ({ request, expected }) => {
|
||||
expect(sig(signEdgeGridRequest(config, request))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signEdgeGridRequest — body hashing rules', () => {
|
||||
test('non-POST methods are NOT body-hashed (PUT with data)', () => {
|
||||
const header = signEdgeGridRequest(CREDS, {
|
||||
method: 'PUT',
|
||||
url: `${HOST}/testapi/v1/t6`,
|
||||
data: 'PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP'
|
||||
});
|
||||
// Matches the Akamai fixture for a PUT (no content hash since only POST is hashed).
|
||||
expect(sig(header)).toBe('GNBWEYSEWOLtu+7dD52da2C39aX/Jchpon3K/AmBqBU=');
|
||||
});
|
||||
|
||||
test('POST body is hashed as-sent (no JSON re-compaction)', () => {
|
||||
const pretty = '{\n "name": "bruno"\n}';
|
||||
const compact = '{"name":"bruno"}';
|
||||
const req = (data) => ({ method: 'POST', url: `${HOST}/x`, data });
|
||||
// Different bytes on the wire ⇒ different signature (proves we hash what we send).
|
||||
expect(sig(signEdgeGridRequest(CREDS, req(pretty)))).not.toBe(sig(signEdgeGridRequest(CREDS, req(compact))));
|
||||
});
|
||||
|
||||
test('POST body is truncated to maxBodySize before hashing', () => {
|
||||
const config = { ...CREDS, maxBodySize: 16 };
|
||||
const a = { method: 'POST', url: `${HOST}/x`, data: 'd'.repeat(16) + 'AAAAAAAA' };
|
||||
const b = { method: 'POST', url: `${HOST}/x`, data: 'd'.repeat(16) + 'BBBBBBBB' };
|
||||
// Bytes beyond maxBodySize are ignored ⇒ identical signature.
|
||||
expect(sig(signEdgeGridRequest(config, a))).toBe(sig(signEdgeGridRequest(config, b)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('signEdgeGridRequest — config behaviour', () => {
|
||||
test('throws when accessToken is missing', () => {
|
||||
expect(() => signEdgeGridRequest({ ...CREDS, accessToken: '' }, { method: 'GET', url: `${HOST}/` })).toThrow(
|
||||
/accessToken is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when clientToken is missing', () => {
|
||||
expect(() => signEdgeGridRequest({ ...CREDS, clientToken: '' }, { method: 'GET', url: `${HOST}/` })).toThrow(
|
||||
/clientToken is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when clientSecret is missing', () => {
|
||||
expect(() => signEdgeGridRequest({ ...CREDS, clientSecret: '' }, { method: 'GET', url: `${HOST}/` })).toThrow(
|
||||
/clientSecret is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('auto-generates nonce and timestamp when not provided', () => {
|
||||
const header = signEdgeGridRequest(
|
||||
{ clientToken: CREDS.clientToken, accessToken: CREDS.accessToken, clientSecret: CREDS.clientSecret },
|
||||
{ method: 'GET', url: `${HOST}/` }
|
||||
);
|
||||
// a UUID v4 nonce and an EdgeGrid timestamp (YYYYMMDDTHH:MM:SS+0000) are present
|
||||
expect(header).toMatch(/nonce=[0-9a-f-]{36};/);
|
||||
expect(header).toMatch(/timestamp=\d{8}T\d{2}:\d{2}:\d{2}\+0000;/);
|
||||
expect(header).toMatch(/signature=.+$/);
|
||||
});
|
||||
|
||||
test('baseURL overrides the host the request is signed against', () => {
|
||||
// Signing the request URL directly vs. signing via a different baseURL host ⇒ different signature
|
||||
const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: `${HOST}/path` });
|
||||
const viaBase = signEdgeGridRequest(
|
||||
{ ...CREDS, baseURL: 'https://other-host.luna.akamaiapis.net' },
|
||||
{ method: 'GET', url: `${HOST}/path` }
|
||||
);
|
||||
expect(sig(direct)).not.toBe(sig(viaBase));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Equivalents of httpie-edgegrid's test_localhost / test_http_to_https_conversion: the EFFECTIVE
|
||||
* signed URL is the baseURL host + the request's path/query, so the literal request host (e.g.
|
||||
* "localhost") is replaced. We prove this by asserting the signature equals signing the fully resolved absolute URL directly.
|
||||
* Reference : https://github.com/akamai/httpie-edgegrid/blob/master/test/test_httpie_edgegrid.py
|
||||
*/
|
||||
describe('signEdgeGridRequest — baseURL host/scheme override (httpie test_localhost equivalents)', () => {
|
||||
const REAL_HOST = 'https://realhost-xxxxxxxx.luna.akamaiapis.net';
|
||||
|
||||
test('localhost is replaced by the baseURL host (test_localhost)', () => {
|
||||
const viaBase = signEdgeGridRequest(
|
||||
{ ...CREDS, baseURL: REAL_HOST },
|
||||
{ method: 'GET', url: 'https://localhost/identity-management/v3/user-profile?q=1' }
|
||||
);
|
||||
const direct = signEdgeGridRequest(CREDS, {
|
||||
method: 'GET',
|
||||
url: `${REAL_HOST}/identity-management/v3/user-profile?q=1`
|
||||
});
|
||||
expect(sig(viaBase)).toBe(sig(direct));
|
||||
});
|
||||
|
||||
test('an http request is signed against the https baseURL (test_http_to_https_conversion)', () => {
|
||||
const viaBase = signEdgeGridRequest({ ...CREDS, baseURL: REAL_HOST }, { method: 'GET', url: 'http://localhost/path' });
|
||||
const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: `${REAL_HOST}/path` });
|
||||
expect(sig(viaBase)).toBe(sig(direct));
|
||||
});
|
||||
|
||||
test('scheme-less baseURL ("host:port") borrows the request scheme', () => {
|
||||
const viaBase = signEdgeGridRequest(
|
||||
{ ...CREDS, baseURL: 'localhost:6000' },
|
||||
{ method: 'GET', url: 'http://localhost:9999/path' }
|
||||
);
|
||||
const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: 'http://localhost:6000/path' });
|
||||
expect(sig(viaBase)).toBe(sig(direct));
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export { addDigestInterceptor } from './digestauth-helper';
|
||||
export { getOAuth2Token } from './oauth2-helper';
|
||||
export { createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './oauth1-request-authorization';
|
||||
export { addEdgeGridInterceptor, signEdgeGridRequest } from './edgegrid-helper';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './auth';
|
||||
export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest, addEdgeGridInterceptor } from './auth';
|
||||
export { GrpcClient, generateGrpcSampleMessage } from './grpc';
|
||||
export { WsClient } from './ws/ws-client';
|
||||
export { default as cookies } from './cookies';
|
||||
|
||||
@@ -38,6 +38,17 @@ export interface AuthApiKey {
|
||||
placement?: 'header' | 'queryparams' | null;
|
||||
}
|
||||
|
||||
export interface AkamaiEdgeGridAuthValues {
|
||||
accessToken?: string | null;
|
||||
clientToken?: string | null;
|
||||
clientSecret?: string | null;
|
||||
nonce?: string | null;
|
||||
timestamp?: string | null;
|
||||
baseURL?: string | null;
|
||||
headersToSign?: string | null;
|
||||
maxBodySize?: number | null;
|
||||
}
|
||||
|
||||
export interface AuthOauth1 {
|
||||
consumerKey?: string | null;
|
||||
consumerSecret?: string | null;
|
||||
@@ -110,7 +121,8 @@ export type AuthMode
|
||||
| 'oauth1'
|
||||
| 'oauth2'
|
||||
| 'wsse'
|
||||
| 'apikey';
|
||||
| 'apikey'
|
||||
| 'akamai-edgegrid';
|
||||
|
||||
export interface Auth {
|
||||
mode: AuthMode;
|
||||
@@ -123,4 +135,5 @@ export interface Auth {
|
||||
oauth2?: OAuth2 | null;
|
||||
wsse?: AuthWsse | null;
|
||||
apikey?: AuthApiKey | null;
|
||||
akamaiEdgegrid?: AkamaiEdgeGridAuthValues | null;
|
||||
}
|
||||
|
||||
@@ -261,6 +261,19 @@ const authApiKeySchema = Yup.object({
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const authEdgeGridSchema = Yup.object({
|
||||
accessToken: Yup.string().nullable(),
|
||||
clientToken: Yup.string().nullable(),
|
||||
clientSecret: Yup.string().nullable(),
|
||||
nonce: Yup.string().nullable(),
|
||||
timestamp: Yup.string().nullable(),
|
||||
baseURL: Yup.string().nullable(),
|
||||
headersToSign: Yup.string().nullable(),
|
||||
maxBodySize: Yup.number().nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const authOAuth1Schema = Yup.object({
|
||||
consumerKey: Yup.string().nullable(),
|
||||
consumerSecret: Yup.string().nullable(),
|
||||
@@ -419,7 +432,7 @@ const oauth2Schema = Yup.object({
|
||||
|
||||
const authSchema = Yup.object({
|
||||
mode: Yup.string()
|
||||
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth1', 'oauth2', 'wsse', 'apikey'])
|
||||
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth1', 'oauth2', 'wsse', 'apikey', 'akamai-edgegrid'])
|
||||
.required('mode is required'),
|
||||
awsv4: authAwsV4Schema.nullable(),
|
||||
basic: authBasicSchema.nullable(),
|
||||
@@ -429,7 +442,8 @@ const authSchema = Yup.object({
|
||||
oauth1: authOAuth1Schema.nullable(),
|
||||
oauth2: oauth2Schema.nullable(),
|
||||
wsse: authWsseSchema.nullable(),
|
||||
apikey: authApiKeySchema.nullable()
|
||||
apikey: authApiKeySchema.nullable(),
|
||||
akamaiEdgegrid: authEdgeGridSchema.nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict()
|
||||
|
||||
157
packages/bruno-tests/src/auth/edgegrid.js
Normal file
157
packages/bruno-tests/src/auth/edgegrid.js
Normal file
@@ -0,0 +1,157 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Local Akamai EdgeGrid (EG1-HMAC-SHA256) verification server — a stand-in for the real
|
||||
* Akamai gateway so EdgeGrid auth can be tested end-to-end without a paid Akamai control API.
|
||||
*
|
||||
* It re-derives the signature from the received request + the known client secret (per the
|
||||
* official EdgeGrid algorithm) and compares. Valid requests → 200, anything else → 401.
|
||||
*
|
||||
* Three things the Authorization header does NOT carry are supplied via test-control headers
|
||||
* (these are never part of headers_to_sign, so they don't affect the client's signature):
|
||||
* - x-eg-headers-to-sign : comma-separated header names the client signed (default: none)
|
||||
* - x-eg-max-body-size : max body size the client used (default: 131072)
|
||||
* For base_url overrides, point the collection's base_url at this server's host so the signed
|
||||
* host matches the received host.
|
||||
*
|
||||
* This is an INDEPENDENT implementation of the spec (it does not import @usebruno/requests),
|
||||
* so it also guards against regressions in the shared signing helper.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Known test credentials. Fixtures import TEST_EDGEGRID so both sides share the secret.
|
||||
const TEST_EDGEGRID = {
|
||||
clientToken: 'akab-client-token-bruno-test',
|
||||
accessToken: 'akab-access-token-bruno-test',
|
||||
clientSecret: 'dGVzdC1jbGllbnQtc2VjcmV0LWZvci1icnVuby1lZGdlZ3JpZA=='
|
||||
};
|
||||
|
||||
const MAX_BODY_SIZE_DEFAULT = 131072;
|
||||
|
||||
const base64HmacSha256 = (data, key) => crypto.createHmac('sha256', key).update(data).digest('base64');
|
||||
const base64Sha256 = (data) => crypto.createHash('sha256').update(data).digest('base64');
|
||||
|
||||
function parseAuthHeader(header) {
|
||||
if (!header || !header.startsWith('EG1-HMAC-SHA256 ')) return null;
|
||||
const out = {};
|
||||
header
|
||||
.slice('EG1-HMAC-SHA256 '.length)
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.forEach((pair) => {
|
||||
const i = pair.indexOf('=');
|
||||
if (i > -1) out[pair.slice(0, i).trim()] = pair.slice(i + 1);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function canonicalizeHeaders(headersToSign, requestHeaders) {
|
||||
if (!headersToSign || !headersToSign.trim()) return '';
|
||||
const lookup = {};
|
||||
Object.keys(requestHeaders || {}).forEach((name) => {
|
||||
lookup[name.toLowerCase()] = requestHeaders[name];
|
||||
});
|
||||
return headersToSign
|
||||
.split(',')
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.filter((name) => Object.prototype.hasOwnProperty.call(lookup, name))
|
||||
.map((name) => `${name}:${String(lookup[name]).trim().replace(/\s+/g, ' ')}`)
|
||||
.join('\t');
|
||||
}
|
||||
|
||||
function makeContentHash(method, rawBody, maxBodySize) {
|
||||
if (!method || method.toUpperCase() !== 'POST') return '';
|
||||
if (!rawBody || rawBody.length === 0) return '';
|
||||
let body = rawBody;
|
||||
if (body.length > maxBodySize) body = body.substring(0, maxBodySize);
|
||||
return base64Sha256(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an incoming EdgeGrid-signed request.
|
||||
* @returns {{ ok: true } | { ok: false, status: number, error: string }}
|
||||
*/
|
||||
function verifyEdgeGridRequest(req, opts = {}) {
|
||||
const clients = opts.clients || { [TEST_EDGEGRID.clientToken]: TEST_EDGEGRID };
|
||||
|
||||
const parsed = parseAuthHeader(req.headers['authorization']);
|
||||
if (!parsed) {
|
||||
return { ok: false, status: 401, error: 'Missing or malformed EG1-HMAC-SHA256 Authorization header' };
|
||||
}
|
||||
for (const field of ['client_token', 'access_token', 'timestamp', 'nonce', 'signature']) {
|
||||
if (!parsed[field]) return { ok: false, status: 401, error: `Authorization missing ${field}` };
|
||||
}
|
||||
|
||||
const client = clients[parsed.client_token];
|
||||
if (!client || client.accessToken !== parsed.access_token) {
|
||||
return { ok: false, status: 401, error: 'Unknown client_token / access_token' };
|
||||
}
|
||||
|
||||
const headersToSign = req.headers['x-eg-headers-to-sign'] || '';
|
||||
const maxBodySize = req.headers['x-eg-max-body-size']
|
||||
? parseInt(req.headers['x-eg-max-body-size'], 10)
|
||||
: MAX_BODY_SIZE_DEFAULT;
|
||||
|
||||
// Reconstruct the URL the client signed (this server sits directly in front of the request).
|
||||
const scheme = req.protocol;
|
||||
const host = req.headers['host'];
|
||||
const u = new URL(`${scheme}://${host}${req.originalUrl}`);
|
||||
|
||||
const authHeader
|
||||
= `EG1-HMAC-SHA256 client_token=${parsed.client_token};access_token=${parsed.access_token};`
|
||||
+ `timestamp=${parsed.timestamp};nonce=${parsed.nonce};`;
|
||||
|
||||
const dataToSign = [
|
||||
req.method.toUpperCase(),
|
||||
u.protocol.replace(':', ''),
|
||||
u.host,
|
||||
u.pathname + u.search,
|
||||
canonicalizeHeaders(headersToSign, req.headers),
|
||||
makeContentHash(req.method, req.rawBody, maxBodySize),
|
||||
authHeader
|
||||
].join('\t');
|
||||
|
||||
const signingKey = base64HmacSha256(parsed.timestamp, client.clientSecret);
|
||||
const expected = base64HmacSha256(dataToSign, signingKey);
|
||||
|
||||
// constant-time compare
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(parsed.signature);
|
||||
const matches = a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
if (!matches) {
|
||||
return { ok: false, status: 401, error: 'Signature mismatch' };
|
||||
}
|
||||
// Echo back the effective values the request was signed with, so tests can assert that
|
||||
// empty Advanced Settings fields were filled in with Bruno's auto-generated defaults
|
||||
// (nonce → UUID v4, timestamp → now, base_url → request host, max_body_size → 131072).
|
||||
return {
|
||||
ok: true,
|
||||
effective: {
|
||||
method: req.method.toUpperCase(),
|
||||
path: u.pathname + u.search,
|
||||
host: u.host,
|
||||
nonce: parsed.nonce,
|
||||
timestamp: parsed.timestamp,
|
||||
headersToSign: headersToSign,
|
||||
maxBodySize
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
router.all('*', (req, res) => {
|
||||
const result = verifyEdgeGridRequest(req);
|
||||
if (!result.ok) {
|
||||
return res.status(result.status).json({ authenticated: false, error: result.error });
|
||||
}
|
||||
return res.status(200).json({ authenticated: true, ...result.effective });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.verifyEdgeGridRequest = verifyEdgeGridRequest;
|
||||
module.exports.TEST_EDGEGRID = TEST_EDGEGRID;
|
||||
@@ -9,8 +9,10 @@ const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials');
|
||||
const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode');
|
||||
const authOAuth2ClientCredentials = require('./oauth2/clientCredentials');
|
||||
const authOAuth1 = require('./oauth1');
|
||||
const authEdgeGrid = require('./edgegrid');
|
||||
|
||||
router.use('/oauth1', authOAuth1);
|
||||
router.use('/edgegrid', authEdgeGrid);
|
||||
router.use('/oauth2/password_credentials', authOAuth2PasswordCredentials);
|
||||
router.use('/oauth2/authorization_code', authOAuth2AuthorizationCode);
|
||||
router.use('/oauth2/client_credentials', authOAuth2ClientCredentials);
|
||||
|
||||
Reference in New Issue
Block a user