feat: add OAuth 1.0 authentication support (#7482)

* feat: add OAuth 1.0 authentication support

Add full OAuth 1.0 (RFC 5849) authentication with support for
HMAC-SHA1/256/512, RSA-SHA1/256/512, and PLAINTEXT signature methods.
Includes UI components, bru/yml serialization, Postman import, code
generation, CLI support, and comprehensive playwright and unit tests.

* test: replace real-looking PEM literals with fake markers in oauth1 tests

Avoid tripping secret scanners by using obviously fake BEGIN/END markers
and non-sensitive base64 content in serialization and round-trip tests.

* fix: remove invalid OAuth1 placeholder header from code generator

OAuth1 requires runtime-computed nonce, timestamp, and signature that
cannot be pre-computed for a static code snippet. Return an empty array
instead of emitting an Authorization header with literal <signature>,
<timestamp>, <nonce> placeholders.

* fix: remove unreachable oauth1 case from WSAuth component

The oauth1 switch branch was dead code since it was not in
supportedAuthModes and the useEffect would reset it to 'none'
before it could render.

* fix: remove unused collectionPath param and use path.basename for filename extraction

* refactor: rename OAuth1 fields for clarity

- tokenSecret → accessTokenSecret
- signatureMethod → signatureEncoding
- addParamsTo value 'queryparams' → 'query'

* refactor: rename addParamsTo to placement in OAuth1 auth

* fix: add missing oauth1: null in buildOAuth2Config and upgrade @opencollection/types to 0.9.0

* test: add oauth1 import tests and fix missing oauth1: null in auth assertions

* ci: add auth playwright tests workflow for Linux, macOS, and Windows

* refactor: rename signatureEncoding to signatureMethod and fix timeline race condition

- Rename OAuth1 signatureEncoding to signatureMethod across all packages
- Fix timeline showing "No Headers/Body found" when request-sent IPC event
  arrives after response by retroactively updating the timeline entry
- Store requestUid in timeline entries for precise matching
- Correct timeline entry timestamp on retroactive update for proper sort order

* ci: add OAuth1 CLI tests and reorganize auth actions under oauth1/

- Add CLI tests that run full BRU and YML collections via bru run
- Add start-test-server actions for Linux, macOS, and Windows
- Move auth e2e and setup actions under auth/oauth1/ directory
- Fix Windows Playwright failures caused by unescaped backslashes in collectionPath template variable

* ci: reorder auth tests to run E2E tests before CLI tests

* ci: start test server after E2E tests to fix port 8081 conflict
This commit is contained in:
lohit
2026-03-27 13:29:42 +00:00
committed by GitHub
parent 784e851d4c
commit 95de14adcb
115 changed files with 7238 additions and 56 deletions

View File

@@ -51,6 +51,11 @@ const AuthMode = ({ collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -0,0 +1,26 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
const CollectionOAuth1 = ({ collection }) => {
const dispatch = useDispatch();
const request = collection.draft?.root
? get(collection, 'draft.root.request', {})
: get(collection, 'root.request', {});
const save = () => dispatch(saveCollectionSettings(collection.uid));
return (
<OAuth1
collection={collection}
request={request}
save={save}
updateAuth={updateCollectionAuth}
/>
);
};
export default CollectionOAuth1;

View File

@@ -12,6 +12,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './Oauth1';
import Button from 'ui/Button';
const Auth = ({ collection }) => {
@@ -37,6 +38,9 @@ const Auth = ({ collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} />;
}

View File

@@ -14,6 +14,7 @@ import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
@@ -143,6 +144,17 @@ const Auth = ({ collection, folder }) => {
/>
);
}
case 'oauth1': {
return (
<OAuth1
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth

View File

@@ -47,6 +47,11 @@ const AuthMode = ({ collection, folder }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -47,6 +47,11 @@ const AuthMode = ({ item, collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -0,0 +1,90 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const Wrapper = styled.div`
.oauth1-icon-container {
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
}
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
}
.oauth1-section-label {
color: ${(props) => props.theme.text};
}
.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};
}
.oauth1-dropdown-selector {
font-size: ${(props) => props.theme.font.size.sm};
padding: 0.2rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.oauth1-dropdown-label {
width: fit-content;
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.private-key-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};
max-width: 400px;
overflow: hidden;
}
input[type='checkbox'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}
.transition-transform {
transition: transform 0.15s ease;
}
.rotate-90 {
transform: rotate(90deg);
}
`;
export default Wrapper;

View File

@@ -0,0 +1,439 @@
import React, { useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import path from 'utils/common/path';
import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import toast from 'react-hot-toast';
import { sendRequest, browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const signatureMethodLabels = {
'HMAC-SHA1': 'HMAC-SHA1',
'HMAC-SHA256': 'HMAC-SHA256',
'HMAC-SHA512': 'HMAC-SHA512',
'RSA-SHA1': 'RSA-SHA1',
'RSA-SHA256': 'RSA-SHA256',
'RSA-SHA512': 'RSA-SHA512',
'PLAINTEXT': 'PLAINTEXT'
};
const placementLabels = {
header: 'Header',
query: 'Query Params',
body: 'Body'
};
const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oauth1 = get(request, 'auth.oauth1', {});
const [advancedOpen, setAdvancedOpen] = useState(false);
const { isSensitive } = useDetectSensitiveField(collection);
const consumerSecretSensitive = isSensitive(oauth1.consumerSecret);
const tokenSecretSensitive = isSensitive(oauth1.accessTokenSecret);
const privateKeySensitive = isSensitive(oauth1.privateKey);
const handleRun = item?.uid ? () => dispatch(sendRequest(item, collection.uid)) : undefined;
const handleSave = () => save();
const handleChange = (field, value) => {
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
[field]: value
}
})
);
};
const handlePrivateKeyChange = (val) => {
if (val && /^@file\(/.test(val.trim())) {
toast.error('File references should be added using the "Upload File" button below');
return;
}
handleChange('privateKey', val);
};
const handleBrowse = () => {
dispatch(browseFiles([], []))
.then((filePaths) => {
if (filePaths && filePaths.length > 0) {
let filePath = filePaths[0];
const collectionDir = collection.pathname;
filePath = path.relative(collectionDir, filePath);
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
privateKey: filePath,
privateKeyType: 'file'
}
})
);
}
})
.catch((error) => console.error(error));
};
const handleClearFile = () => {
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
privateKey: '',
privateKeyType: 'text'
}
})
);
};
const privateKeyValue = oauth1.privateKey || '';
const isFileRef = oauth1.privateKeyType === 'file';
const fileName = isFileRef ? path.basename(privateKeyValue) : '';
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{/* Configuration Section */}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconSettings size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Consumer Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.consumerKey || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('consumerKey', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
{!oauth1.signatureMethod?.startsWith('RSA-') && (
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Consumer Secret</label>
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oauth1.consumerSecret || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('consumerSecret', val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
isCompact
/>
{consumerSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-consumer-secret" warningMessage={consumerSecretSensitive.warningMessage} />}
</div>
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Token</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.accessToken || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('accessToken', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Token Secret</label>
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oauth1.accessTokenSecret || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('accessTokenSecret', val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
isCompact
/>
{tokenSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-token-secret" warningMessage={tokenSecretSensitive.warningMessage} />}
</div>
</div>
{/* Signature Section */}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconShieldLock size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Signature
</span>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Signature Method</label>
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
<MenuDropdown
items={Object.entries(signatureMethodLabels).map(([value, label]) => ({
id: value,
label,
onClick: () => handleChange('signatureMethod', value)
}))}
selectedItemId={oauth1.signatureMethod}
placement="bottom-end"
>
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
{signatureMethodLabels[oauth1.signatureMethod] || 'HMAC-SHA1'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
{oauth1.signatureMethod?.startsWith('RSA-') && (
<div className="flex items-start gap-4 w-full">
<label className="block min-w-[140px] mt-1">Private Key</label>
{isFileRef ? (
<div className="private-key-editor-wrapper flex-1 flex items-center gap-2">
<IconFile size={16} className="oauth1-icon flex-shrink-0" />
<span className="truncate flex-1" title={privateKeyValue}>{fileName}</span>
<button
className="flex-shrink-0 oauth1-icon cursor-pointer"
onClick={handleClearFile}
title="Clear file"
type="button"
>
<IconX size={14} />
</button>
</div>
) : (
<div className="flex flex-1 flex-col gap-2">
<div className="private-key-editor-wrapper flex-1 flex items-center">
<MultiLineEditor
value={privateKeyValue}
theme={storedTheme}
onSave={handleSave}
onChange={handlePrivateKeyChange}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
allowNewlines={true}
/>
{privateKeySensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-private-key" warningMessage={privateKeySensitive.warningMessage} />}
</div>
<div className="flex flex-row gap-2">
<button
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
onClick={handleBrowse}
title="Select file"
type="button"
>
<IconUpload size={14} />
<span className="text-xs">Upload File</span>
</button>
</div>
</div>
)}
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Add Params To</label>
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
<MenuDropdown
items={Object.entries(placementLabels).map(([value, label]) => ({
id: value,
label,
onClick: () => handleChange('placement', value)
}))}
selectedItemId={oauth1.placement}
placement="bottom-end"
>
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
{placementLabels[oauth1.placement] || 'Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
{oauth1.placement === 'body' && (
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]"></label>
<span className="text-xs opacity-60">
Body placement requires a form-urlencoded body. Non-form payloads will be replaced with OAuth parameters.
</span>
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={oauth1.includeBodyHash || false}
onChange={(e) => handleChange('includeBodyHash', e.target.checked)}
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault(); handleChange('includeBodyHash', !oauth1.includeBodyHash);
}}
>
Include Body Hash
</label>
</div>
</div>
{/* Advanced Section (collapsible) */}
<div
className="flex items-center gap-2.5 mt-2 cursor-pointer select-none"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconAdjustmentsHorizontal size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Advanced
</span>
<IconChevronRight
size={14}
className={`oauth1-icon transition-transform ${advancedOpen ? 'rotate-90' : ''}`}
/>
</div>
{advancedOpen && (
<>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Callback URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.callbackUrl || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Verifier</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.verifier || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('verifier', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Timestamp</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.timestamp || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('timestamp', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Nonce</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.nonce || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('nonce', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Version</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.version || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('version', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Realm</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.realm || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('realm', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
</>
)}
</StyledWrapper>
);
};
export default OAuth1;

View File

@@ -6,6 +6,7 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './OAuth1';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
@@ -90,6 +91,9 @@ const Auth = ({ item, collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}

View File

@@ -93,12 +93,12 @@ const WSAuth = ({ item, collection }) => {
case 'inherit': {
const source = getEffectiveAuthSource();
// Check if inherited auth is OAuth2 - not supported for WebSockets
if (source?.auth?.mode === 'oauth2') {
// Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
OAuth 2 not <strong>yet</strong> supported by WebSockets. Using no auth instead.
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
</div>
</>
);

View File

@@ -370,7 +370,7 @@ const RequestTabPanel = () => {
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
<section className="request-pane" data-testid="request-pane">
<div
className="px-4 h-full"
style={requestPaneStyle}
@@ -390,7 +390,7 @@ const RequestTabPanel = () => {
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto">
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
{renderResponsePane()}
</section>
</section>

View File

@@ -169,7 +169,7 @@ const ResponseExample = ({ item, collection, example }) => {
onTryExample={handleTryExample}
/>
<section ref={mainSectionRef} className={`main wrapper flex mt-4 ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto scrollbar-hover`}>
<section className="request-pane">
<section className="request-pane" data-testid="request-pane">
<div
className="px-4 h-full"
style={isVerticalLayout ? {
@@ -195,7 +195,7 @@ const ResponseExample = ({ item, collection, example }) => {
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto">
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
<ResponseExampleResponsePane
item={item}
collection={collection}

View File

@@ -549,6 +549,7 @@ export const collectionsSlice = createSlice({
collectionUid: collection.uid,
folderUid: null,
itemUid: item.uid,
requestUid: item.requestUid,
timestamp: timestamp,
data: {
request: item.requestSent || item.request,
@@ -1017,6 +1018,10 @@ export const collectionsSlice = createSlice({
item.draft.request.auth.mode = 'ntlm';
item.draft.request.auth.ntlm = action.payload.content;
break;
case 'oauth1':
item.draft.request.auth.mode = 'oauth1';
item.draft.request.auth.oauth1 = action.payload.content;
break;
case 'oauth2':
item.draft.request.auth.mode = 'oauth2';
item.draft.request.auth.oauth2 = action.payload.content;
@@ -2121,6 +2126,9 @@ export const collectionsSlice = createSlice({
case 'ntlm':
set(collection, 'draft.root.request.auth.ntlm', action.payload.content);
break;
case 'oauth1':
set(collection, 'draft.root.request.auth.oauth1', action.payload.content);
break;
case 'oauth2':
set(collection, 'draft.root.request.auth.oauth2', action.payload.content);
break;
@@ -2454,6 +2462,9 @@ export const collectionsSlice = createSlice({
case 'ntlm':
set(folder, 'draft.request.auth.ntlm', action.payload.content);
break;
case 'oauth1':
set(folder, 'draft.request.auth.oauth1', action.payload.content);
break;
case 'apikey':
set(folder, 'draft.request.auth.apikey', action.payload.content);
break;
@@ -2984,6 +2995,23 @@ export const collectionsSlice = createSlice({
item.requestState = 'sending';
item.cancelTokenUid = cancelTokenUid;
}
// If response was already received (race condition: responseReceived fired before
// request-sent arrived), retroactively update the timeline entry that was created
// with the raw item.request fallback so it has the actual sent request data.
if (item.requestState === 'received' && Array.isArray(collection.timeline)) {
for (let i = collection.timeline.length - 1; i >= 0; i--) {
const entry = collection.timeline[i];
if (entry.itemUid === item.uid && entry.requestUid === requestUid && entry.type === 'request') {
entry.data.request = requestSent;
if (requestSent.timestamp) {
entry.timestamp = requestSent.timestamp;
entry.data.timestamp = requestSent.timestamp;
}
break;
}
}
}
}
if (type === 'assertion-results') {

View File

@@ -46,6 +46,10 @@ export const getAuthHeaders = (requestAuth, collection = null, item = null) => {
];
}
return [];
case 'oauth1':
// OAuth1 requires runtime signing (nonce, timestamp, signature) that
// cannot be pre-computed for a static code snippet.
return [];
case 'oauth2': {
const oauth2Config = get(requestAuth, 'oauth2', {});
const tokenPlacement = get(oauth2Config, 'tokenPlacement', 'header');

View File

@@ -384,6 +384,25 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
domain: get(si.request, 'auth.ntlm.domain', '')
};
break;
case 'oauth1':
di.request.auth.oauth1 = {
consumerKey: get(si.request, 'auth.oauth1.consumerKey', ''),
consumerSecret: get(si.request, 'auth.oauth1.consumerSecret', ''),
accessToken: get(si.request, 'auth.oauth1.accessToken', ''),
accessTokenSecret: get(si.request, 'auth.oauth1.accessTokenSecret', ''),
callbackUrl: get(si.request, 'auth.oauth1.callbackUrl', ''),
verifier: get(si.request, 'auth.oauth1.verifier', ''),
signatureMethod: get(si.request, 'auth.oauth1.signatureMethod', 'HMAC-SHA1'),
privateKey: get(si.request, 'auth.oauth1.privateKey', ''),
privateKeyType: get(si.request, 'auth.oauth1.privateKeyType', 'text'),
timestamp: get(si.request, 'auth.oauth1.timestamp', ''),
nonce: get(si.request, 'auth.oauth1.nonce', ''),
version: get(si.request, 'auth.oauth1.version', '1.0'),
realm: get(si.request, 'auth.oauth1.realm', ''),
placement: get(si.request, 'auth.oauth1.placement', 'header'),
includeBodyHash: get(si.request, 'auth.oauth1.includeBodyHash', false)
};
break;
case 'oauth2':
let grantType = get(si.request, 'auth.oauth2.grantType', '');
switch (grantType) {
@@ -925,6 +944,10 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'NTLM';
break;
}
case 'oauth1': {
label = 'OAuth 1.0';
break;
}
case 'oauth2': {
label = 'OAuth 2.0';
break;

View File

@@ -277,6 +277,22 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
// interpolate vars for oauth1config auth
if (request.oauth1config) {
request.oauth1config.consumerKey = _interpolate(request.oauth1config.consumerKey) || '';
request.oauth1config.consumerSecret = _interpolate(request.oauth1config.consumerSecret) || '';
request.oauth1config.accessToken = _interpolate(request.oauth1config.accessToken) || '';
request.oauth1config.accessTokenSecret = _interpolate(request.oauth1config.accessTokenSecret) || '';
request.oauth1config.callbackUrl = _interpolate(request.oauth1config.callbackUrl) || '';
request.oauth1config.verifier = _interpolate(request.oauth1config.verifier) || '';
request.oauth1config.signatureMethod = _interpolate(request.oauth1config.signatureMethod) || request.oauth1config.signatureMethod || 'HMAC-SHA1';
request.oauth1config.privateKey = _interpolate(request.oauth1config.privateKey) || '';
request.oauth1config.timestamp = _interpolate(request.oauth1config.timestamp) || '';
request.oauth1config.nonce = _interpolate(request.oauth1config.nonce) || '';
request.oauth1config.version = _interpolate(request.oauth1config.version) || '';
request.oauth1config.realm = _interpolate(request.oauth1config.realm) || '';
}
if (request?.auth) delete request.auth;
if (request) return request;

View File

@@ -149,6 +149,26 @@ const prepareRequest = async (item = {}, collection = {}) => {
};
}
if (collectionAuth.mode === 'oauth1') {
axiosRequest.oauth1config = {
consumerKey: get(collectionAuth, 'oauth1.consumerKey'),
consumerSecret: get(collectionAuth, 'oauth1.consumerSecret'),
accessToken: get(collectionAuth, 'oauth1.accessToken'),
accessTokenSecret: get(collectionAuth, 'oauth1.accessTokenSecret'),
callbackUrl: get(collectionAuth, 'oauth1.callbackUrl'),
verifier: get(collectionAuth, 'oauth1.verifier'),
signatureMethod: get(collectionAuth, 'oauth1.signatureMethod'),
privateKey: get(collectionAuth, 'oauth1.privateKey'),
privateKeyType: get(collectionAuth, 'oauth1.privateKeyType'),
timestamp: get(collectionAuth, 'oauth1.timestamp'),
nonce: get(collectionAuth, 'oauth1.nonce'),
version: get(collectionAuth, 'oauth1.version'),
realm: get(collectionAuth, 'oauth1.realm'),
placement: get(collectionAuth, 'oauth1.placement'),
includeBodyHash: get(collectionAuth, 'oauth1.includeBodyHash')
};
}
if (collectionAuth.mode === 'wsse') {
const username = get(collectionAuth, 'wsse.username', '');
const password = get(collectionAuth, 'wsse.password', '');
@@ -166,8 +186,6 @@ const prepareRequest = async (item = {}, collection = {}) => {
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
}
console.log('axiosRequest', axiosRequest);
}
if (request.auth && request.auth.mode !== 'inherit') {
@@ -197,6 +215,26 @@ const prepareRequest = async (item = {}, collection = {}) => {
};
}
if (request.auth.mode === 'oauth1') {
axiosRequest.oauth1config = {
consumerKey: get(request, 'auth.oauth1.consumerKey'),
consumerSecret: get(request, 'auth.oauth1.consumerSecret'),
accessToken: get(request, 'auth.oauth1.accessToken'),
accessTokenSecret: get(request, 'auth.oauth1.accessTokenSecret'),
callbackUrl: get(request, 'auth.oauth1.callbackUrl'),
verifier: get(request, 'auth.oauth1.verifier'),
signatureMethod: get(request, 'auth.oauth1.signatureMethod'),
privateKey: get(request, 'auth.oauth1.privateKey'),
privateKeyType: get(request, 'auth.oauth1.privateKeyType'),
timestamp: get(request, 'auth.oauth1.timestamp'),
nonce: get(request, 'auth.oauth1.nonce'),
version: get(request, 'auth.oauth1.version'),
realm: get(request, 'auth.oauth1.realm'),
placement: get(request, 'auth.oauth1.placement'),
includeBodyHash: get(request, 'auth.oauth1.includeBodyHash')
};
}
if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token', '')}`;
}

View File

@@ -22,7 +22,7 @@ const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
@@ -701,6 +701,14 @@ const runSingleRequest = async function (
delete request.ntlmConfig;
}
if (request.oauth1config) {
try {
applyOAuth1ToRequest(request, collectionPath);
} catch (error) {
throw new Error(`OAuth1 signing failed: ${error.message}`);
}
}
if (request.awsv4config) {
// todo: make this happen in prepare-request.js
// interpolate the aws v4 config

View File

@@ -28,7 +28,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@opencollection/types": "~0.8.0",
"@opencollection/types": "0.9.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",

View File

@@ -7,8 +7,10 @@ import type {
AuthAwsV4,
AuthApiKey,
AuthWsse,
AuthOAuth1,
AuthOAuth2,
BrunoAuth,
BrunoAuthOauth1,
BrunoOAuth2
} from '../types';
@@ -49,6 +51,7 @@ const fromOpenCollectionOAuth2 = (auth: AuthOAuth2): BrunoAuth => {
bearer: null,
digest: null,
ntlm: null,
oauth1: null,
oauth2: {
grantType: base.grantType || 'client_credentials',
username: base.username || null,
@@ -157,6 +160,7 @@ const fromOpenCollectionOAuth2 = (auth: AuthOAuth2): BrunoAuth => {
bearer: null,
digest: null,
ntlm: null,
oauth1: null,
oauth2: null,
wsse: null,
apikey: null
@@ -172,6 +176,7 @@ export const fromOpenCollectionAuth = (auth: Auth | undefined): BrunoAuth => {
bearer: null,
digest: null,
ntlm: null,
oauth1: null,
oauth2: null,
wsse: null,
apikey: null
@@ -275,6 +280,31 @@ export const fromOpenCollectionAuth = (auth: Auth | undefined): BrunoAuth => {
};
}
case 'oauth1': {
const oauth1Auth = auth as AuthOAuth1;
return {
...defaultAuth,
mode: 'oauth1',
oauth1: {
consumerKey: oauth1Auth.consumerKey || null,
consumerSecret: oauth1Auth.consumerSecret || null,
accessToken: oauth1Auth.accessToken || null,
accessTokenSecret: oauth1Auth.accessTokenSecret || null,
callbackUrl: oauth1Auth.callbackUrl || null,
verifier: oauth1Auth.verifier || null,
signatureMethod: (oauth1Auth.signatureEncoding as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
privateKey: (typeof oauth1Auth.privateKey === 'object' && oauth1Auth.privateKey ? oauth1Auth.privateKey.value : oauth1Auth.privateKey) || null,
privateKeyType: (typeof oauth1Auth.privateKey === 'object' && oauth1Auth.privateKey ? oauth1Auth.privateKey.type : 'text') as BrunoAuthOauth1['privateKeyType'],
timestamp: oauth1Auth.timestamp || null,
nonce: oauth1Auth.nonce || null,
version: oauth1Auth.version || '1.0',
realm: oauth1Auth.realm || null,
placement: (oauth1Auth.placement as BrunoAuthOauth1['placement']) || 'header',
includeBodyHash: oauth1Auth.includeBodyHash || false
}
};
}
case 'oauth2':
return fromOpenCollectionOAuth2(auth as AuthOAuth2);
@@ -461,6 +491,29 @@ export const toOpenCollectionAuth = (auth: BrunoAuth | null | undefined): Auth |
password: auth.wsse?.password || ''
};
case 'oauth1': {
const oauth1: AuthOAuth1 = {
type: 'oauth1',
consumerKey: auth.oauth1?.consumerKey || '',
consumerSecret: auth.oauth1?.consumerSecret || '',
accessToken: auth.oauth1?.accessToken || '',
accessTokenSecret: auth.oauth1?.accessTokenSecret || '',
callbackUrl: auth.oauth1?.callbackUrl || '',
verifier: auth.oauth1?.verifier || '',
signatureEncoding: auth.oauth1?.signatureMethod || 'HMAC-SHA1',
privateKey: auth.oauth1?.privateKeyType === 'file'
? { type: 'file' as const, value: auth.oauth1?.privateKey || '' }
: { type: 'text' as const, value: auth.oauth1?.privateKey || '' },
timestamp: auth.oauth1?.timestamp || '',
nonce: auth.oauth1?.nonce || '',
version: auth.oauth1?.version || '1.0',
realm: auth.oauth1?.realm || '',
placement: auth.oauth1?.placement || 'header',
includeBodyHash: auth.oauth1?.includeBodyHash || false
};
return oauth1;
}
case 'oauth2':
return toOpenCollectionOAuth2(auth.oauth2);

View File

@@ -102,7 +102,8 @@ export type {
AuthNTLM,
AuthAwsV4,
AuthApiKey,
AuthWsse
AuthWsse,
AuthOAuth1
} from '@opencollection/types/common/auth';
export type { AuthOAuth2 } from '@opencollection/types/common/auth-oauth2';
@@ -140,6 +141,7 @@ export type {
AuthNTLM as BrunoAuthNTLM,
AuthWsse as BrunoAuthWsse,
AuthApiKey as BrunoAuthApiKey,
AuthOauth1 as BrunoAuthOauth1,
OAuth2 as BrunoOAuth2
} from '@usebruno/schema-types/common/auth';
export type { MultipartFormEntry as BrunoMultipartFormEntry, MultipartForm as BrunoMultipartForm } from '@usebruno/schema-types/common/multipart-form';

View File

@@ -11,6 +11,7 @@ const AUTH_TYPES = Object.freeze({
AWSV4: 'awsv4',
APIKEY: 'apikey',
DIGEST: 'digest',
OAUTH1: 'oauth1',
OAUTH2: 'oauth2',
NOAUTH: 'noauth',
NONE: 'none'
@@ -226,6 +227,25 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
password: authValues.password || ''
};
break;
case AUTH_TYPES.OAUTH1:
requestObject.auth.oauth1 = {
consumerKey: authValues.consumerKey || '',
consumerSecret: authValues.consumerSecret || '',
accessToken: authValues.token || '',
accessTokenSecret: authValues.tokenSecret || '',
callbackUrl: authValues.callback || null,
verifier: authValues.verifier || null,
signatureMethod: authValues.signatureMethod || 'HMAC-SHA1',
privateKey: authValues.privateKey || null,
privateKeyType: 'text',
timestamp: authValues.timestamp || null,
nonce: authValues.nonce || null,
version: authValues.version || '1.0',
realm: authValues.realm || null,
placement: authValues.addParamsToHeader === false ? 'query' : 'header',
includeBodyHash: authValues.includeBodyHash || false
};
break;
case AUTH_TYPES.OAUTH2:
const findValueUsingKey = (key) => authValues[key] || '';
@@ -327,6 +347,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
@@ -391,6 +412,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
@@ -788,6 +810,7 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) =>
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},

View File

@@ -38,6 +38,7 @@ describe('Collection Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -97,6 +98,7 @@ describe('Collection Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -150,6 +152,7 @@ describe('Collection Authentication', () => {
},
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -209,6 +212,7 @@ describe('Collection Authentication', () => {
value: 'apikey',
placement: 'header'
},
oauth1: null,
oauth2: null,
digest: null
});
@@ -269,6 +273,7 @@ describe('Collection Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: {
username: 'digest auth',
@@ -318,6 +323,7 @@ describe('Collection Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -364,6 +370,7 @@ describe('Collection Authentication', () => {
},
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});

View File

@@ -58,7 +58,8 @@ describe('Folder Authentication', () => {
awsv4: null,
apikey: null,
oauth2: null,
digest: null
digest: null,
oauth1: null
});
});
@@ -121,7 +122,8 @@ describe('Folder Authentication', () => {
awsv4: null,
apikey: null,
oauth2: null,
digest: null
digest: null,
oauth1: null
});
});
@@ -183,6 +185,7 @@ describe('Folder Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -238,6 +241,7 @@ describe('Folder Authentication', () => {
bearer: { token: 'token' },
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -298,6 +302,7 @@ describe('Folder Authentication', () => {
bearer: null,
awsv4: null,
apikey: { key: 'apikey', value: 'apikey', placement: 'header' },
oauth1: null,
oauth2: null,
digest: null
});
@@ -363,6 +368,7 @@ describe('Folder Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: { username: 'digest user', password: 'digest pass' }
});
@@ -415,6 +421,7 @@ describe('Folder Authentication', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});

View File

@@ -411,6 +411,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -422,6 +423,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -466,6 +468,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -511,6 +514,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -522,6 +526,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -566,6 +571,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -577,6 +583,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -626,6 +633,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -637,6 +645,7 @@ describe('postman-collection', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
});
@@ -1101,6 +1110,7 @@ const expectedOutput = {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
@@ -1130,6 +1140,7 @@ const expectedOutput = {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
@@ -1154,6 +1165,7 @@ const expectedOutput = {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
@@ -1184,6 +1196,7 @@ const expectedOutput = {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},

View File

@@ -11,6 +11,7 @@ describe('processAuth', () => {
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
}
@@ -509,7 +510,134 @@ describe('processAuth', () => {
expect(requestObject.auth.bearer).toBe(null);
expect(requestObject.auth.awsv4).toBe(null);
expect(requestObject.auth.apikey).toBe(null);
expect(requestObject.auth.oauth1).toBe(null);
expect(requestObject.auth.oauth2).toBe(null);
expect(requestObject.auth.digest).toBe(null);
});
it('should handle oauth1 auth with all fields (v2.1 object format)', () => {
const auth = {
type: 'oauth1',
oauth1: {
consumerKey: 'test-consumer-key',
consumerSecret: 'test-consumer-secret',
token: 'test-token',
tokenSecret: 'test-token-secret',
signatureMethod: 'HMAC-SHA256',
callback: 'https://callback.example.com',
verifier: 'test-verifier',
timestamp: '1234567890',
nonce: 'test-nonce',
version: '1.0',
realm: 'test-realm',
addParamsToHeader: true,
includeBodyHash: true,
privateKey: 'test-private-key'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth1');
expect(requestObject.auth.oauth1).toEqual({
consumerKey: 'test-consumer-key',
consumerSecret: 'test-consumer-secret',
accessToken: 'test-token',
accessTokenSecret: 'test-token-secret',
callbackUrl: 'https://callback.example.com',
verifier: 'test-verifier',
signatureMethod: 'HMAC-SHA256',
privateKey: 'test-private-key',
privateKeyType: 'text',
timestamp: '1234567890',
nonce: 'test-nonce',
version: '1.0',
realm: 'test-realm',
placement: 'header',
includeBodyHash: true
});
});
it('should handle oauth1 auth with v2.1 array format', () => {
const auth = {
type: 'oauth1',
oauth1: [
{ key: 'consumerKey', value: 'ck-array', type: 'string' },
{ key: 'consumerSecret', value: 'cs-array', type: 'string' },
{ key: 'token', value: 'tk-array', type: 'string' },
{ key: 'tokenSecret', value: 'ts-array', type: 'string' },
{ key: 'signatureMethod', value: 'HMAC-SHA1', type: 'string' },
{ key: 'addParamsToHeader', value: false, type: 'boolean' }
]
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth1');
expect(requestObject.auth.oauth1.consumerKey).toBe('ck-array');
expect(requestObject.auth.oauth1.consumerSecret).toBe('cs-array');
expect(requestObject.auth.oauth1.accessToken).toBe('tk-array');
expect(requestObject.auth.oauth1.accessTokenSecret).toBe('ts-array');
expect(requestObject.auth.oauth1.signatureMethod).toBe('HMAC-SHA1');
expect(requestObject.auth.oauth1.placement).toBe('query');
});
it('should handle oauth1 auth with missing values', () => {
const auth = {
type: 'oauth1',
oauth1: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth1');
expect(requestObject.auth.oauth1).toEqual({
consumerKey: '',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: null,
verifier: null,
signatureMethod: 'HMAC-SHA1',
privateKey: null,
privateKeyType: 'text',
timestamp: null,
nonce: null,
version: '1.0',
realm: null,
placement: 'header',
includeBodyHash: false
});
});
it('should handle oauth1 auth with missing oauth1 key', () => {
const auth = {
type: 'oauth1'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth1');
expect(requestObject.auth.oauth1).toEqual({
consumerKey: '',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: null,
verifier: null,
signatureMethod: 'HMAC-SHA1',
privateKey: null,
privateKeyType: 'text',
timestamp: null,
nonce: null,
version: '1.0',
realm: null,
placement: 'header',
includeBodyHash: false
});
});
it('should handle oauth1 addParamsToHeader false as query', () => {
const auth = {
type: 'oauth1',
oauth1: {
consumerKey: 'ck',
addParamsToHeader: false
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.oauth1.placement).toBe('query');
});
});

View File

@@ -38,6 +38,7 @@ describe('Request Authentication', () => {
awsv4: null,
apikey: null,
oauth2: null,
oauth1: null,
digest: null
});
});
@@ -77,7 +78,8 @@ describe('Request Authentication', () => {
awsv4: null,
apikey: null,
oauth2: null,
digest: null
digest: null,
oauth1: null
});
});
@@ -117,7 +119,8 @@ describe('Request Authentication', () => {
awsv4: null,
apikey: null,
oauth2: null,
digest: null
digest: null,
oauth1: null
});
});
@@ -158,12 +161,12 @@ describe('Request Authentication', () => {
// Check folder first
expect(result.items[0].root.request.auth).toEqual({
mode: 'inherit',
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Then check request
expect(result.items[0].items[0].request.auth).toEqual({
mode: 'inherit',
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
});
@@ -205,7 +208,8 @@ describe('Request Authentication', () => {
awsv4: null,
apikey: null,
oauth2: null,
digest: null
digest: null,
oauth1: null
});
});
@@ -251,19 +255,19 @@ describe('Request Authentication', () => {
// Check Folder Level 1
expect(result.items[0].root.request.auth).toEqual({
mode: 'inherit',
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Check Folder Level 2
expect(result.items[0].items[0].root.request.auth).toEqual({
mode: 'inherit',
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Check the Request
expect(result.items[0].items[0].items[0].request.auth).toEqual({
mode: 'inherit',
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
});
@@ -314,22 +318,209 @@ describe('Request Authentication', () => {
mode: 'bearer',
basic: null,
bearer: { token: 'folder1Token' }, // Explicitly set
awsv4: null, apikey: null, oauth2: null, digest: null
awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Check Folder Level 2
expect(result.items[0].items[0].root.request.auth).toEqual({
mode: 'inherit', // Inherits from Folder 1
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Check the Request
expect(result.items[0].items[0].items[0].request.auth).toEqual({
mode: 'inherit', // Inherits from Folder 1
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
});
it('should handle oauth1 auth with HMAC-SHA1 and placement query (addParamsToHeader false)', async () => {
const postmanCollection = {
info: {
name: 'OAuth1 HMAC Query Collection',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'OAuth1 HMAC Query Request',
request: {
method: 'GET',
url: 'http://www.example.com',
auth: {
type: 'oauth1',
oauth1: [
{ key: 'consumerKey', value: 'consumer_key', type: 'string' },
{ key: 'consumerSecret', value: 'consumer_secret', type: 'string' },
{ key: 'token', value: 'access_token', type: 'string' },
{ key: 'tokenSecret', value: 'token_secret', type: 'string' },
{ key: 'signatureMethod', value: 'HMAC-SHA1', type: 'string' },
{ key: 'version', value: '1.0', type: 'string' },
{ key: 'addParamsToHeader', value: false, type: 'boolean' },
{ key: 'includeBodyHash', value: true, type: 'boolean' },
{ key: 'callback', value: 'https://www.example.com', type: 'string' },
{ key: 'verifier', value: 'verifier', type: 'string' }
]
}
}
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].request.auth).toEqual({
mode: 'oauth1',
basic: null,
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null,
oauth1: {
consumerKey: 'consumer_key',
consumerSecret: 'consumer_secret',
accessToken: 'access_token',
accessTokenSecret: 'token_secret',
callbackUrl: 'https://www.example.com',
verifier: 'verifier',
signatureMethod: 'HMAC-SHA1',
privateKey: null,
privateKeyType: 'text',
timestamp: null,
nonce: null,
version: '1.0',
realm: null,
placement: 'query',
includeBodyHash: true
}
});
});
it('should handle oauth1 auth with HMAC-SHA1 and placement header (addParamsToHeader true)', async () => {
const postmanCollection = {
info: {
name: 'OAuth1 HMAC Header Collection',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'OAuth1 HMAC Header Request',
request: {
method: 'GET',
url: 'http://www.example.com',
auth: {
type: 'oauth1',
oauth1: [
{ key: 'consumerKey', value: 'consumer_key', type: 'string' },
{ key: 'consumerSecret', value: 'consumer_secret', type: 'string' },
{ key: 'token', value: 'access_token', type: 'string' },
{ key: 'tokenSecret', value: 'token_secret', type: 'string' },
{ key: 'signatureMethod', value: 'HMAC-SHA1', type: 'string' },
{ key: 'version', value: '1.0', type: 'string' },
{ key: 'addParamsToHeader', value: true, type: 'boolean' },
{ key: 'includeBodyHash', value: true, type: 'boolean' },
{ key: 'callback', value: 'https://www.example.com', type: 'string' },
{ key: 'verifier', value: 'verifier', type: 'string' }
]
}
}
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].request.auth.mode).toBe('oauth1');
expect(result.items[0].request.auth.oauth1.placement).toBe('header');
expect(result.items[0].request.auth.oauth1.consumerKey).toBe('consumer_key');
expect(result.items[0].request.auth.oauth1.accessToken).toBe('access_token');
expect(result.items[0].request.auth.oauth1.accessTokenSecret).toBe('token_secret');
});
it('should handle oauth1 auth with RSA-SHA1 and private key', async () => {
const privateKey = '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----';
const postmanCollection = {
info: {
name: 'OAuth1 RSA Collection',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'OAuth1 RSA Request',
request: {
method: 'GET',
url: 'http://www.example.com',
auth: {
type: 'oauth1',
oauth1: [
{ key: 'consumerKey', value: 'consumer_key', type: 'string' },
{ key: 'consumerSecret', value: 'consumer_secret', type: 'string' },
{ key: 'token', value: 'access_token', type: 'string' },
{ key: 'tokenSecret', value: 'token_secret', type: 'string' },
{ key: 'signatureMethod', value: 'RSA-SHA1', type: 'string' },
{ key: 'privateKey', value: privateKey, type: 'string' },
{ key: 'version', value: '1.0', type: 'string' },
{ key: 'addParamsToHeader', value: true, type: 'boolean' },
{ key: 'includeBodyHash', value: true, type: 'boolean' },
{ key: 'callback', value: 'https://www.example.com', type: 'string' },
{ key: 'verifier', value: 'verifier', type: 'string' }
]
}
}
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].request.auth.mode).toBe('oauth1');
expect(result.items[0].request.auth.oauth1.signatureMethod).toBe('RSA-SHA1');
expect(result.items[0].request.auth.oauth1.privateKey).toBe(privateKey);
expect(result.items[0].request.auth.oauth1.privateKeyType).toBe('text');
expect(result.items[0].request.auth.oauth1.placement).toBe('header');
});
it('should handle oauth1 auth at collection level', async () => {
const postmanCollection = {
info: {
name: 'OAuth1 Collection Level',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
auth: {
type: 'oauth1',
oauth1: [
{ key: 'consumerKey', value: 'col_consumer_key', type: 'string' },
{ key: 'consumerSecret', value: 'col_consumer_secret', type: 'string' },
{ key: 'token', value: 'col_access_token', type: 'string' },
{ key: 'tokenSecret', value: 'col_token_secret', type: 'string' },
{ key: 'signatureMethod', value: 'HMAC-SHA1', type: 'string' },
{ key: 'addParamsToHeader', value: true, type: 'boolean' }
]
},
item: [
{
name: 'Inheriting Request',
request: {
method: 'GET',
url: 'http://www.example.com'
}
}
]
};
const result = await postmanToBruno(postmanCollection);
// Collection root should have oauth1
expect(result.root.request.auth.mode).toBe('oauth1');
expect(result.root.request.auth.oauth1.consumerKey).toBe('col_consumer_key');
expect(result.root.request.auth.oauth1.accessToken).toBe('col_access_token');
expect(result.root.request.auth.oauth1.placement).toBe('header');
// Request should inherit
expect(result.items[0].request.auth.mode).toBe('inherit');
expect(result.items[0].request.auth.oauth1).toBe(null);
});
it('should handle "Inherit Auth" where an intermediate folder has explicit "No Auth"', async () => {
const postmanCollection = {
info: {
@@ -374,19 +565,19 @@ describe('Request Authentication', () => {
// Check Folder Level 1
expect(result.items[0].root.request.auth).toEqual({
mode: 'none', // Explicitly "No Auth"
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Check Folder Level 2
expect(result.items[0].items[0].root.request.auth).toEqual({
mode: 'inherit', // Inherits from Folder 1
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
// Check the Request
expect(result.items[0].items[0].items[0].request.auth).toEqual({
mode: 'inherit', // Inherits from Folder 1
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null
basic: null, bearer: null, awsv4: null, apikey: null, oauth2: null, digest: null, oauth1: null
});
});
});

View File

@@ -1,6 +1,7 @@
const https = require('https');
const axios = require('axios');
const path = require('path');
const { applyOAuth1ToRequest } = require('@usebruno/requests');
const qs = require('qs');
const decomment = require('decomment');
const contentDispositionParser = require('content-disposition');
@@ -158,6 +159,14 @@ const configureRequest = async (
delete request.ntlmConfig;
}
if (request.oauth1config) {
try {
applyOAuth1ToRequest(request, collectionPath);
} catch (error) {
throw new Error(`OAuth1 signing failed: ${error.message}`);
}
}
if (request.oauth2) {
let requestCopy = cloneDeep(request);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, tokenSource, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {};

View File

@@ -361,6 +361,22 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
}
// interpolate vars for oauth1config auth
if (request.oauth1config) {
request.oauth1config.consumerKey = _interpolate(request.oauth1config.consumerKey) || '';
request.oauth1config.consumerSecret = _interpolate(request.oauth1config.consumerSecret) || '';
request.oauth1config.accessToken = _interpolate(request.oauth1config.accessToken) || '';
request.oauth1config.accessTokenSecret = _interpolate(request.oauth1config.accessTokenSecret) || '';
request.oauth1config.callbackUrl = _interpolate(request.oauth1config.callbackUrl) || '';
request.oauth1config.verifier = _interpolate(request.oauth1config.verifier) || '';
request.oauth1config.signatureMethod = _interpolate(request.oauth1config.signatureMethod) || request.oauth1config.signatureMethod || 'HMAC-SHA1';
request.oauth1config.privateKey = _interpolate(request.oauth1config.privateKey) || '';
request.oauth1config.timestamp = _interpolate(request.oauth1config.timestamp) || '';
request.oauth1config.nonce = _interpolate(request.oauth1config.nonce) || '';
request.oauth1config.version = _interpolate(request.oauth1config.version) || '';
request.oauth1config.realm = _interpolate(request.oauth1config.realm) || '';
}
if (request?.auth) delete request.auth;
return request;

View File

@@ -44,6 +44,25 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
domain: get(collectionAuth, 'ntlm.domain')
};
break;
case 'oauth1':
axiosRequest.oauth1config = {
consumerKey: get(collectionAuth, 'oauth1.consumerKey'),
consumerSecret: get(collectionAuth, 'oauth1.consumerSecret'),
accessToken: get(collectionAuth, 'oauth1.accessToken'),
accessTokenSecret: get(collectionAuth, 'oauth1.accessTokenSecret'),
callbackUrl: get(collectionAuth, 'oauth1.callbackUrl'),
verifier: get(collectionAuth, 'oauth1.verifier'),
signatureMethod: get(collectionAuth, 'oauth1.signatureMethod'),
privateKey: get(collectionAuth, 'oauth1.privateKey'),
privateKeyType: get(collectionAuth, 'oauth1.privateKeyType'),
timestamp: get(collectionAuth, 'oauth1.timestamp'),
nonce: get(collectionAuth, 'oauth1.nonce'),
version: get(collectionAuth, 'oauth1.version'),
realm: get(collectionAuth, 'oauth1.realm'),
placement: get(collectionAuth, 'oauth1.placement'),
includeBodyHash: get(collectionAuth, 'oauth1.includeBodyHash')
};
break;
case 'wsse':
const username = get(collectionAuth, 'wsse.username', '');
const password = get(collectionAuth, 'wsse.password', '');
@@ -192,6 +211,26 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(request, 'auth.ntlm.password'),
domain: get(request, 'auth.ntlm.domain')
};
break;
case 'oauth1':
axiosRequest.oauth1config = {
consumerKey: get(request, 'auth.oauth1.consumerKey'),
consumerSecret: get(request, 'auth.oauth1.consumerSecret'),
accessToken: get(request, 'auth.oauth1.accessToken'),
accessTokenSecret: get(request, 'auth.oauth1.accessTokenSecret'),
callbackUrl: get(request, 'auth.oauth1.callbackUrl'),
verifier: get(request, 'auth.oauth1.verifier'),
signatureMethod: get(request, 'auth.oauth1.signatureMethod'),
privateKey: get(request, 'auth.oauth1.privateKey'),
privateKeyType: get(request, 'auth.oauth1.privateKeyType'),
timestamp: get(request, 'auth.oauth1.timestamp'),
nonce: get(request, 'auth.oauth1.nonce'),
version: get(request, 'auth.oauth1.version'),
realm: get(request, 'auth.oauth1.realm'),
placement: get(request, 'auth.oauth1.placement'),
includeBodyHash: get(request, 'auth.oauth1.includeBodyHash')
};
break;
case 'oauth2':
const grantType = get(request, 'auth.oauth2.grantType');
switch (grantType) {

View File

@@ -6,9 +6,10 @@ import type {
AuthBearer,
AuthDigest,
AuthNTLM,
AuthOAuth1,
AuthWsse
} from '@opencollection/types/common/auth';
import type { Auth as BrunoAuth } from '@usebruno/schema-types/common/auth';
import type { Auth as BrunoAuth, AuthOauth1 as BrunoAuthOauth1 } from '@usebruno/schema-types/common/auth';
import { isString } from '../../../utils';
import { toOpenCollectionOAuth2, toBrunoOAuth2 } from './auth-oauth2';
@@ -115,6 +116,35 @@ const buildApiKeyAuth = (config?: BrunoAuth['apikey']): AuthApiKey => {
return auth;
};
const buildOAuth1Auth = (config?: BrunoAuth['oauth1']): AuthOAuth1 => {
const auth: AuthOAuth1 = { type: 'oauth1' };
if (!config) {
return auth;
}
if (isString(config.consumerKey)) auth.consumerKey = config.consumerKey;
if (isString(config.consumerSecret)) auth.consumerSecret = config.consumerSecret;
if (isString(config.accessToken)) auth.accessToken = config.accessToken;
if (isString(config.accessTokenSecret)) auth.accessTokenSecret = config.accessTokenSecret;
if (isString(config.callbackUrl)) auth.callbackUrl = config.callbackUrl;
if (isString(config.verifier)) auth.verifier = config.verifier;
if (isString(config.signatureMethod)) auth.signatureEncoding = config.signatureMethod;
if (isString(config.privateKey)) {
auth.privateKey = config.privateKeyType === 'file'
? { type: 'file' as const, value: config.privateKey }
: { type: 'text' as const, value: config.privateKey };
}
if (isString(config.timestamp)) auth.timestamp = config.timestamp;
if (isString(config.nonce)) auth.nonce = config.nonce;
if (isString(config.version)) auth.version = config.version;
if (isString(config.realm)) auth.realm = config.realm;
if (isString(config.placement)) auth.placement = config.placement as AuthOAuth1['placement'];
if (typeof config.includeBodyHash === 'boolean') auth.includeBodyHash = config.includeBodyHash;
return auth;
};
export const toOpenCollectionAuth = (auth?: BrunoAuth | null): Auth | undefined => {
if (!auth || auth.mode === 'none') {
return undefined;
@@ -139,6 +169,8 @@ export const toOpenCollectionAuth = (auth?: BrunoAuth | null): Auth | undefined
return buildWsseAuth(auth.wsse);
case 'apikey':
return buildApiKeyAuth(auth.apikey);
case 'oauth1':
return buildOAuth1Auth(auth.oauth1);
case 'oauth2':
return toOpenCollectionOAuth2(auth.oauth2);
default:
@@ -231,6 +263,27 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null =>
};
break;
case 'oauth1':
brunoAuth.mode = 'oauth1';
brunoAuth.oauth1 = {
consumerKey: auth.consumerKey || null,
consumerSecret: auth.consumerSecret || null,
accessToken: auth.accessToken || null,
accessTokenSecret: auth.accessTokenSecret || null,
callbackUrl: auth.callbackUrl || null,
verifier: auth.verifier || null,
signatureMethod: (auth.signatureEncoding as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
privateKey: (typeof auth.privateKey === 'object' && auth.privateKey ? auth.privateKey.value : auth.privateKey) || null,
privateKeyType: (typeof auth.privateKey === 'object' && auth.privateKey ? auth.privateKey.type : 'text') as BrunoAuthOauth1['privateKeyType'],
timestamp: auth.timestamp || null,
nonce: auth.nonce || null,
version: auth.version || '1.0',
realm: auth.realm || null,
placement: (auth.placement as BrunoAuthOauth1['placement']) || 'header',
includeBodyHash: auth.includeBodyHash || false
};
break;
case 'oauth2':
brunoAuth.mode = 'oauth2';
brunoAuth.oauth2 = toBrunoOAuth2(auth);

View File

@@ -96,6 +96,8 @@ class BrunoRequest {
getAuthMode() {
if (this.req?.oauth2) {
return 'oauth2';
} else if (this.req?.oauth1config) {
return 'oauth1';
} else if (this.headers?.['Authorization']?.startsWith('Bearer')) {
return 'bearer';
} else if (this.headers?.['Authorization']?.startsWith('Basic') || this.req?.auth?.username) {

View File

@@ -31,7 +31,7 @@ const parseExample = require('./example/bruToJson');
*/
const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | settings | docs | example)*
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey | authOauth2Configs
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws
bodyforms = bodyformurlencoded | bodymultipart | bodyfile
params = paramspath | paramsquery
@@ -121,6 +121,7 @@ const grammar = ohm.grammar(`Bru {
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
authNTLM = "auth:ntlm" dictionary
authOAuth1 = "auth:oauth1" dictionary
authOAuth2 = "auth:oauth2" dictionary
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary
@@ -710,6 +711,40 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authOAuth1(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const findValue = (name) => {
const item = _.find(auth, { name });
return item ? item.value : '';
};
return {
auth: {
oauth1: {
consumerKey: findValue('consumer_key'),
consumerSecret: findValue('consumer_secret'),
accessToken: findValue('access_token'),
accessTokenSecret: findValue('token_secret'),
callbackUrl: findValue('callback_url'),
verifier: findValue('verifier'),
signatureMethod: findValue('signature_method'),
privateKey: (() => {
const val = findValue('private_key');
return val && val.startsWith('@file(') && val.endsWith(')') ? val.slice(6, -1) : val;
})(),
privateKeyType: (() => {
const val = findValue('private_key');
return val && val.startsWith('@file(') && val.endsWith(')') ? 'file' : 'text';
})(),
timestamp: findValue('timestamp'),
nonce: findValue('nonce'),
version: findValue('version'),
realm: findValue('realm'),
placement: findValue('placement'),
includeBodyHash: findValue('include_body_hash') === 'true'
}
}
};
},
authOAuth2(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const grantTypeKey = _.find(auth, { name: 'grant_type' });

View File

@@ -4,7 +4,7 @@ const { safeParseJson, outdentString } = require('./utils');
const grammar = ohm.grammar(`Bru {
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM |authOAuth2 | authwsse | authapikey | authOauth2Configs
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs
// Oauth2 additional parameters
authOauth2Configs = oauth2AuthReqConfig | oauth2AccessTokenReqConfig | oauth2RefreshTokenReqConfig
@@ -68,6 +68,7 @@ const grammar = ohm.grammar(`Bru {
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
authNTLM = "auth:ntlm" dictionary
authOAuth1 = "auth:oauth1" dictionary
authOAuth2 = "auth:oauth2" dictionary
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary
@@ -323,6 +324,40 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authOAuth1(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const findValue = (name) => {
const item = _.find(auth, { name });
return item ? item.value : '';
};
return {
auth: {
oauth1: {
consumerKey: findValue('consumer_key'),
consumerSecret: findValue('consumer_secret'),
accessToken: findValue('access_token'),
accessTokenSecret: findValue('token_secret'),
callbackUrl: findValue('callback_url'),
verifier: findValue('verifier'),
signatureMethod: findValue('signature_method'),
privateKey: (() => {
const val = findValue('private_key');
return val && val.startsWith('@file(') && val.endsWith(')') ? val.slice(6, -1) : val;
})(),
privateKeyType: (() => {
const val = findValue('private_key');
return val && val.startsWith('@file(') && val.endsWith(')') ? 'file' : 'text';
})(),
timestamp: findValue('timestamp'),
nonce: findValue('nonce'),
version: findValue('version'),
realm: findValue('realm'),
placement: findValue('placement'),
includeBodyHash: findValue('include_body_hash') === 'true'
}
}
};
},
authOAuth2(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const grantTypeKey = _.find(auth, { name: 'grant_type' });

View File

@@ -251,6 +251,27 @@ ${indentString(`domain: ${auth?.ntlm?.domain || ''}`)}
}
`;
}
if (auth && auth.oauth1) {
bru += `auth:oauth1 {
${indentString(`consumer_key: ${auth?.oauth1?.consumerKey || ''}`)}
${indentString(`consumer_secret: ${auth?.oauth1?.consumerSecret || ''}`)}
${indentString(`access_token: ${auth?.oauth1?.accessToken || ''}`)}
${indentString(`token_secret: ${auth?.oauth1?.accessTokenSecret || ''}`)}
${indentString(`callback_url: ${auth?.oauth1?.callbackUrl || ''}`)}
${indentString(`verifier: ${auth?.oauth1?.verifier || ''}`)}
${indentString(`signature_method: ${auth?.oauth1?.signatureMethod || ''}`)}
${indentString(`private_key: ${auth?.oauth1?.privateKeyType === 'file' ? `@file(${auth?.oauth1?.privateKey || ''})` : getValueString(auth?.oauth1?.privateKey || '')}`)}
${indentString(`timestamp: ${auth?.oauth1?.timestamp || ''}`)}
${indentString(`nonce: ${auth?.oauth1?.nonce || ''}`)}
${indentString(`version: ${auth?.oauth1?.version || ''}`)}
${indentString(`realm: ${auth?.oauth1?.realm || ''}`)}
${indentString(`placement: ${auth?.oauth1?.placement || ''}`)}
${indentString(`include_body_hash: ${(auth?.oauth1?.includeBodyHash || false).toString()}`)}
}
`;
}

View File

@@ -140,6 +140,27 @@ ${indentString(`key: ${auth?.apikey?.key || ''}`)}
${indentString(`value: ${auth?.apikey?.value || ''}`)}
${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
}
`;
}
if (auth && auth.oauth1) {
bru += `auth:oauth1 {
${indentString(`consumer_key: ${auth?.oauth1?.consumerKey || ''}`)}
${indentString(`consumer_secret: ${auth?.oauth1?.consumerSecret || ''}`)}
${indentString(`access_token: ${auth?.oauth1?.accessToken || ''}`)}
${indentString(`token_secret: ${auth?.oauth1?.accessTokenSecret || ''}`)}
${indentString(`callback_url: ${auth?.oauth1?.callbackUrl || ''}`)}
${indentString(`verifier: ${auth?.oauth1?.verifier || ''}`)}
${indentString(`signature_method: ${auth?.oauth1?.signatureMethod || ''}`)}
${indentString(`private_key: ${auth?.oauth1?.privateKeyType === 'file' ? `@file(${auth?.oauth1?.privateKey || ''})` : getValueString(auth?.oauth1?.privateKey || '')}`)}
${indentString(`timestamp: ${auth?.oauth1?.timestamp || ''}`)}
${indentString(`nonce: ${auth?.oauth1?.nonce || ''}`)}
${indentString(`version: ${auth?.oauth1?.version || ''}`)}
${indentString(`realm: ${auth?.oauth1?.realm || ''}`)}
${indentString(`placement: ${auth?.oauth1?.placement || ''}`)}
${indentString(`include_body_hash: ${(auth?.oauth1?.includeBodyHash || false).toString()}`)}
}
`;
}

View File

@@ -0,0 +1,850 @@
const bruToJson = require('../src/bruToJson');
const jsonToBru = require('../src/jsonToBru');
const collectionBruToJson = require('../src/collectionBruToJson');
const jsonToCollectionBru = require('../src/jsonToCollectionBru');
// ---------------------------------------------------------------------------
// bruToJson request-level parsing
// ---------------------------------------------------------------------------
describe('OAuth1 bruToJson (request-level)', () => {
it('should parse all oauth1 fields with text private key', () => {
const input = `
meta {
name: OAuth1 Test
type: http
seq: 1
}
get {
url: https://api.example.com/resource
body: none
auth: oauth1
}
auth:oauth1 {
consumer_key: my_consumer_key
consumer_secret: my_consumer_secret
access_token: my_access_token
token_secret: my_token_secret
callback_url: https://example.com/callback
verifier: my_verifier
signature_method: HMAC-SHA1
private_key: my_private_key
timestamp: 1234567890
nonce: abc123
version: 1.0
realm: my_realm
placement: header
include_body_hash: true
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1).toEqual({
consumerKey: 'my_consumer_key',
consumerSecret: 'my_consumer_secret',
accessToken: 'my_access_token',
accessTokenSecret: 'my_token_secret',
callbackUrl: 'https://example.com/callback',
verifier: 'my_verifier',
signatureMethod: 'HMAC-SHA1',
privateKey: 'my_private_key',
privateKeyType: 'text',
timestamp: '1234567890',
nonce: 'abc123',
version: '1.0',
realm: 'my_realm',
placement: 'header',
includeBodyHash: true
});
});
it('should parse empty/missing optional fields as empty strings', () => {
const input = `
meta {
name: Minimal OAuth1
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret:
access_token:
token_secret:
callback_url:
verifier:
signature_method: HMAC-SHA1
private_key:
timestamp:
nonce:
version:
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1.consumerKey).toBe('ck');
expect(result.auth.oauth1.consumerSecret).toBe('');
expect(result.auth.oauth1.accessToken).toBe('');
expect(result.auth.oauth1.accessTokenSecret).toBe('');
expect(result.auth.oauth1.callbackUrl).toBe('');
expect(result.auth.oauth1.verifier).toBe('');
expect(result.auth.oauth1.privateKey).toBe('');
expect(result.auth.oauth1.privateKeyType).toBe('text');
expect(result.auth.oauth1.timestamp).toBe('');
expect(result.auth.oauth1.nonce).toBe('');
expect(result.auth.oauth1.version).toBe('');
expect(result.auth.oauth1.realm).toBe('');
expect(result.auth.oauth1.includeBodyHash).toBe(false);
});
it('should parse @file() private key as file type', () => {
const input = `
meta {
name: OAuth1 File Key
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret:
access_token: at
token_secret: ts
callback_url:
verifier:
signature_method: RSA-SHA1
private_key: @file(keys/my-private-key.pem)
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1.privateKey).toBe('keys/my-private-key.pem');
expect(result.auth.oauth1.privateKeyType).toBe('file');
});
it('should parse multiline private key (triple-quoted PEM)', () => {
const input = `
meta {
name: OAuth1 Multiline PEM
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret: cs
access_token: at
token_secret: ts
callback_url:
verifier:
signature_method: RSA-SHA1
private_key: '''
-----BEGIN FAKE TEST KEY-----
TESTREPLACEMENTdGhpcyBpcyBub3QgYQ==
-----END FAKE TEST KEY-----
'''
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1.privateKeyType).toBe('text');
expect(result.auth.oauth1.privateKey).toContain('-----BEGIN FAKE TEST KEY-----');
expect(result.auth.oauth1.privateKey).toContain('-----END FAKE TEST KEY-----');
expect(result.auth.oauth1.privateKey).toContain('TESTREPLACEMENTdGhpcyBpcyBub3QgYQ==');
// Verify no leading spaces are preserved in the parsed key lines
const keyLines = result.auth.oauth1.privateKey.split('\n').filter((l) => l.length > 0);
keyLines.forEach((line) => {
expect(line).not.toMatch(/^\s/);
});
});
it('should parse variable reference in private key as text type', () => {
const input = `
meta {
name: OAuth1 Variable Key
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret:
access_token: at
token_secret: ts
callback_url:
verifier:
signature_method: RSA-SHA1
private_key: {{my_private_key}}
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1.privateKey).toBe('{{my_private_key}}');
expect(result.auth.oauth1.privateKeyType).toBe('text');
});
it('should parse all signature methods correctly', () => {
const signatureMethods = ['HMAC-SHA1', 'HMAC-SHA256', 'HMAC-SHA512', 'RSA-SHA1', 'RSA-SHA256', 'RSA-SHA512', 'PLAINTEXT'];
for (const method of signatureMethods) {
const input = `
meta {
name: OAuth1 ${method}
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret: cs
access_token: at
token_secret: ts
callback_url:
verifier:
signature_method: ${method}
private_key:
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1.signatureMethod).toBe(method);
}
});
it('should parse placement values: header, query, body', () => {
for (const placement of ['header', 'query', 'body']) {
const input = `
meta {
name: OAuth1 Params To ${placement}
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret: cs
access_token: at
token_secret: ts
callback_url:
verifier:
signature_method: HMAC-SHA1
private_key:
timestamp:
nonce:
version: 1.0
realm:
placement: ${placement}
include_body_hash: false
}
`.trim();
const result = bruToJson(input);
expect(result.auth.oauth1.placement).toBe(placement);
}
});
it('should parse include_body_hash true and false', () => {
const makeInput = (val) => `
meta {
name: OAuth1 Body Hash
type: http
}
get {
url: https://api.example.com/resource
auth: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret: cs
access_token:
token_secret:
callback_url:
verifier:
signature_method: HMAC-SHA1
private_key:
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: ${val}
}
`.trim();
expect(bruToJson(makeInput('true')).auth.oauth1.includeBodyHash).toBe(true);
expect(bruToJson(makeInput('false')).auth.oauth1.includeBodyHash).toBe(false);
});
});
// ---------------------------------------------------------------------------
// collectionBruToJson collection/folder-level parsing
// ---------------------------------------------------------------------------
describe('OAuth1 collectionBruToJson (collection/folder-level)', () => {
it('should parse all oauth1 fields at collection level', () => {
const input = `
auth {
mode: oauth1
}
auth:oauth1 {
consumer_key: col_consumer_key
consumer_secret: col_consumer_secret
access_token: col_access_token
token_secret: col_token_secret
callback_url: https://col.example.com/cb
verifier: col_verifier
signature_method: HMAC-SHA256
private_key: col_private_key
timestamp: 9999999999
nonce: col_nonce
version: 1.0
realm: col_realm
placement: query
include_body_hash: true
}
`.trim();
const result = collectionBruToJson(input);
expect(result.auth.mode).toBe('oauth1');
expect(result.auth.oauth1).toEqual({
consumerKey: 'col_consumer_key',
consumerSecret: 'col_consumer_secret',
accessToken: 'col_access_token',
accessTokenSecret: 'col_token_secret',
callbackUrl: 'https://col.example.com/cb',
verifier: 'col_verifier',
signatureMethod: 'HMAC-SHA256',
privateKey: 'col_private_key',
privateKeyType: 'text',
timestamp: '9999999999',
nonce: 'col_nonce',
version: '1.0',
realm: 'col_realm',
placement: 'query',
includeBodyHash: true
});
});
it('should parse @file() private key at collection level', () => {
const input = `
auth {
mode: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret:
access_token:
token_secret:
callback_url:
verifier:
signature_method: RSA-SHA1
private_key: @file(certs/private.pem)
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = collectionBruToJson(input);
expect(result.auth.oauth1.privateKey).toBe('certs/private.pem');
expect(result.auth.oauth1.privateKeyType).toBe('file');
});
it('should parse multiline private key at collection level', () => {
const input = `
auth {
mode: oauth1
}
auth:oauth1 {
consumer_key: ck
consumer_secret:
access_token:
token_secret:
callback_url:
verifier:
signature_method: RSA-SHA256
private_key: '''
-----BEGIN FAKE RSA TEST KEY-----
RkFLRUtFWXJlYWxrZXlkYXRhZm9ydGVz
-----END FAKE RSA TEST KEY-----
'''
timestamp:
nonce:
version: 1.0
realm:
placement: header
include_body_hash: false
}
`.trim();
const result = collectionBruToJson(input);
expect(result.auth.oauth1.privateKeyType).toBe('text');
expect(result.auth.oauth1.privateKey).toContain('-----BEGIN FAKE RSA TEST KEY-----');
expect(result.auth.oauth1.privateKey).toContain('RkFLRUtFWXJlYWxrZXlkYXRhZm9ydGVz');
expect(result.auth.oauth1.privateKey).toContain('-----END FAKE RSA TEST KEY-----');
// Verify no leading spaces are preserved in the parsed key lines
const keyLines = result.auth.oauth1.privateKey.split('\n').filter((l) => l.length > 0);
keyLines.forEach((line) => {
expect(line).not.toMatch(/^\s/);
});
});
});
// ---------------------------------------------------------------------------
// jsonToBru request-level serialization
// ---------------------------------------------------------------------------
describe('OAuth1 jsonToBru (request-level)', () => {
it('should serialize all oauth1 fields with text private key', () => {
const json = {
meta: { name: 'OAuth1 Serialize', type: 'http', seq: 1 },
http: { method: 'get', url: 'https://api.example.com/resource', body: 'none', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: 'cs',
accessToken: 'at',
accessTokenSecret: 'ts',
callbackUrl: 'https://example.com/cb',
verifier: 'v',
signatureMethod: 'HMAC-SHA1',
privateKey: 'pk',
privateKeyType: 'text',
timestamp: '123',
nonce: 'n',
version: '1.0',
realm: 'r',
placement: 'header',
includeBodyHash: false
}
}
};
const bru = jsonToBru(json);
expect(bru).toContain('auth:oauth1 {');
expect(bru).toContain('consumer_key: ck');
expect(bru).toContain('consumer_secret: cs');
expect(bru).toContain('access_token: at');
expect(bru).toContain('token_secret: ts');
expect(bru).toContain('callback_url: https://example.com/cb');
expect(bru).toContain('verifier: v');
expect(bru).toContain('signature_method: HMAC-SHA1');
expect(bru).toContain('private_key: pk');
expect(bru).toContain('timestamp: 123');
expect(bru).toContain('nonce: n');
expect(bru).toContain('version: 1.0');
expect(bru).toContain('realm: r');
expect(bru).toContain('placement: header');
expect(bru).toContain('include_body_hash: false');
});
it('should serialize file private key with @file() wrapper', () => {
const json = {
meta: { name: 'OAuth1 File', type: 'http', seq: 1 },
http: { method: 'get', url: 'https://api.example.com/resource', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: '',
verifier: '',
signatureMethod: 'RSA-SHA1',
privateKey: 'keys/private.pem',
privateKeyType: 'file',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'header',
includeBodyHash: false
}
}
};
const bru = jsonToBru(json);
expect(bru).toContain('private_key: @file(keys/private.pem)');
});
it('should serialize multiline private key with triple quotes', () => {
const pem = '-----BEGIN FAKE TEST KEY-----\nTESTREPLACEMENTdGhpcyBpcyBub3QgYQ==\nRkFLRUtFWXJlYWxrZXlkYXRhZm9ydGVz\n-----END FAKE TEST KEY-----';
const json = {
meta: { name: 'OAuth1 PEM', type: 'http', seq: 1 },
http: { method: 'get', url: 'https://api.example.com/resource', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: '',
verifier: '',
signatureMethod: 'RSA-SHA1',
privateKey: pem,
privateKeyType: 'text',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'header',
includeBodyHash: false
}
}
};
const bru = jsonToBru(json);
expect(bru).toContain('private_key: \'\'\'');
expect(bru).toContain('-----BEGIN FAKE TEST KEY-----');
expect(bru).toContain('-----END FAKE TEST KEY-----');
});
it('should serialize empty optional fields', () => {
const json = {
meta: { name: 'OAuth1 Empty', type: 'http', seq: 1 },
http: { method: 'get', url: 'https://api.example.com/resource', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: '',
verifier: '',
signatureMethod: 'HMAC-SHA1',
privateKey: '',
privateKeyType: 'text',
timestamp: '',
nonce: '',
version: '',
realm: '',
placement: 'header',
includeBodyHash: false
}
}
};
const bru = jsonToBru(json);
// Empty fields should still be present
expect(bru).toMatch(/consumer_secret:\s*$/m);
expect(bru).toMatch(/access_token:\s*$/m);
expect(bru).toMatch(/token_secret:\s*$/m);
expect(bru).toMatch(/callback_url:\s*$/m);
expect(bru).toMatch(/verifier:\s*$/m);
expect(bru).toMatch(/private_key:\s*$/m);
expect(bru).toMatch(/timestamp:\s*$/m);
expect(bru).toMatch(/nonce:\s*$/m);
expect(bru).toMatch(/version:\s*$/m);
expect(bru).toMatch(/realm:\s*$/m);
});
});
// ---------------------------------------------------------------------------
// jsonToCollectionBru collection/folder-level serialization
// ---------------------------------------------------------------------------
describe('OAuth1 jsonToCollectionBru (collection/folder-level)', () => {
it('should serialize oauth1 at collection level', () => {
const json = {
auth: {
mode: 'oauth1',
oauth1: {
consumerKey: 'col_ck',
consumerSecret: 'col_cs',
accessToken: 'col_at',
accessTokenSecret: 'col_ts',
callbackUrl: '',
verifier: '',
signatureMethod: 'HMAC-SHA256',
privateKey: '',
privateKeyType: 'text',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'query',
includeBodyHash: true
}
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('auth {');
expect(bru).toContain('mode: oauth1');
expect(bru).toContain('auth:oauth1 {');
expect(bru).toContain('consumer_key: col_ck');
expect(bru).toContain('consumer_secret: col_cs');
expect(bru).toContain('signature_method: HMAC-SHA256');
expect(bru).toContain('placement: query');
expect(bru).toContain('include_body_hash: true');
});
it('should serialize @file() private key at collection level', () => {
const json = {
auth: {
mode: 'oauth1',
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: '',
verifier: '',
signatureMethod: 'RSA-SHA1',
privateKey: 'certs/key.pem',
privateKeyType: 'file',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'header',
includeBodyHash: false
}
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('private_key: @file(certs/key.pem)');
});
});
// ---------------------------------------------------------------------------
// Round-trip tests bruToJson → jsonToBru → bruToJson
// ---------------------------------------------------------------------------
describe('OAuth1 round-trip (request-level)', () => {
it('should survive round-trip with all fields populated', () => {
const json = {
meta: { name: 'OAuth1 Roundtrip', type: 'http', seq: '1' },
http: { method: 'get', url: 'https://api.example.com/resource', body: 'none', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: 'cs',
accessToken: 'at',
accessTokenSecret: 'ts',
callbackUrl: 'https://example.com/cb',
verifier: 'ver',
signatureMethod: 'HMAC-SHA1',
privateKey: 'inline_pk',
privateKeyType: 'text',
timestamp: '1234567890',
nonce: 'abc',
version: '1.0',
realm: 'testrealm',
placement: 'header',
includeBodyHash: true
}
},
settings: { encodeUrl: true, timeout: 0 }
};
const bru = jsonToBru(json);
const parsed = bruToJson(bru);
expect(parsed.auth.oauth1).toEqual(json.auth.oauth1);
});
it('should survive round-trip with file private key', () => {
const json = {
meta: { name: 'OAuth1 File RT', type: 'http', seq: '1' },
http: { method: 'get', url: 'https://api.example.com/resource', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: 'at',
accessTokenSecret: 'ts',
callbackUrl: '',
verifier: '',
signatureMethod: 'RSA-SHA1',
privateKey: 'keys/private.pem',
privateKeyType: 'file',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'header',
includeBodyHash: false
}
},
settings: { encodeUrl: true, timeout: 0 }
};
const bru = jsonToBru(json);
const parsed = bruToJson(bru);
expect(parsed.auth.oauth1.privateKey).toBe('keys/private.pem');
expect(parsed.auth.oauth1.privateKeyType).toBe('file');
});
it('should survive round-trip with multiline PEM private key', () => {
const pem = '-----BEGIN FAKE TEST KEY-----\nTESTREPLACEMENTdGhpcyBpcyBub3QgYQ==\nRkFLRUtFWXJlYWxrZXlkYXRhZm9ydGVz\n-----END FAKE TEST KEY-----';
const json = {
meta: { name: 'OAuth1 PEM RT', type: 'http', seq: '1' },
http: { method: 'get', url: 'https://api.example.com/resource', auth: 'oauth1' },
auth: {
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: '',
verifier: '',
signatureMethod: 'RSA-SHA256',
privateKey: pem,
privateKeyType: 'text',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'header',
includeBodyHash: false
}
},
settings: { encodeUrl: true, timeout: 0 }
};
const bru = jsonToBru(json);
const parsed = bruToJson(bru);
expect(parsed.auth.oauth1.privateKey).toBe(pem);
expect(parsed.auth.oauth1.privateKeyType).toBe('text');
});
});
describe('OAuth1 round-trip (collection-level)', () => {
it('should survive round-trip at collection level', () => {
const json = {
auth: {
mode: 'oauth1',
oauth1: {
consumerKey: 'ck',
consumerSecret: 'cs',
accessToken: 'at',
accessTokenSecret: 'ts',
callbackUrl: 'https://example.com/cb',
verifier: 'ver',
signatureMethod: 'HMAC-SHA512',
privateKey: '',
privateKeyType: 'text',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'body',
includeBodyHash: false
}
}
};
const bru = jsonToCollectionBru(json);
const parsed = collectionBruToJson(bru);
expect(parsed.auth.mode).toBe('oauth1');
expect(parsed.auth.oauth1).toEqual(json.auth.oauth1);
});
it('should survive round-trip with file key at collection level', () => {
const json = {
auth: {
mode: 'oauth1',
oauth1: {
consumerKey: 'ck',
consumerSecret: '',
accessToken: '',
accessTokenSecret: '',
callbackUrl: '',
verifier: '',
signatureMethod: 'RSA-SHA512',
privateKey: 'keys/rsa.pem',
privateKeyType: 'file',
timestamp: '',
nonce: '',
version: '1.0',
realm: '',
placement: 'header',
includeBodyHash: false
}
}
};
const bru = jsonToCollectionBru(json);
const parsed = collectionBruToJson(bru);
expect(parsed.auth.oauth1.privateKey).toBe('keys/rsa.pem');
expect(parsed.auth.oauth1.privateKeyType).toBe('file');
});
});

View File

@@ -31,11 +31,11 @@
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"is-ip": "^5.0.1",
"shell-env": "^4.0.1",
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",
"ws": "^8.18.3",
"shell-env": "^4.0.1"
"ws": "^8.18.3"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",

View File

@@ -1,2 +1,3 @@
export { addDigestInterceptor } from './digestauth-helper';
export { getOAuth2Token } from './oauth2-helper';
export { createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './oauth1-request-authorization';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
// OAuth 1.0 request authorization (RFC 5849)
// Logic referred from https://github.com/ddo/oauth-1.0a
import crypto from 'node:crypto';
import fs from 'node:fs';
import nodePath from 'node:path';
// Private key file cache: avoids re-reading the same file on every request.
// Keyed by absolute path; invalidated when the file's mtime changes.
// Capped at 50 entries to prevent unbounded growth in long-running processes.
const PRIVATE_KEY_CACHE_MAX = 50;
const privateKeyCache = new Map<string, { mtimeMs: number; content: string }>();
function readPrivateKeyFile(filePath: string): string {
const stat = fs.statSync(filePath);
const cached = privateKeyCache.get(filePath);
if (cached && cached.mtimeMs === stat.mtimeMs) {
return cached.content;
}
const content = fs.readFileSync(filePath, 'utf-8');
if (privateKeyCache.size >= PRIVATE_KEY_CACHE_MAX) {
// Evict the oldest entry (first inserted)
const oldestKey = privateKeyCache.keys().next().value;
if (oldestKey !== undefined) {
privateKeyCache.delete(oldestKey);
}
}
privateKeyCache.set(filePath, { mtimeMs: stat.mtimeMs, content });
return content;
}
export type SignatureMethod = 'HMAC-SHA1' | 'HMAC-SHA256' | 'HMAC-SHA512' | 'RSA-SHA1' | 'RSA-SHA256' | 'RSA-SHA512' | 'PLAINTEXT';
export interface OAuth1Config {
consumer: { key: string; secret: string };
signature_method: SignatureMethod;
version?: string;
realm?: string;
private_key?: string;
hash_function?: (baseString: string, key: string) => string;
}
export interface OAuth1RequestData {
url: string;
method: string;
data?: Array<[string, string]>;
}
export interface OAuth1Token {
key: string;
secret: string;
}
export interface OAuth1AuthData {
oauth_consumer_key: string;
oauth_nonce: string;
oauth_signature_method: string;
oauth_timestamp: string;
oauth_version: string;
oauth_token?: string;
oauth_signature: string;
oauth_body_hash?: string;
[key: string]: string | undefined;
}
// RFC 5849 percent-encoding
export function percentEncode(str: string): string {
return encodeURIComponent(str)
.replace(/!/g, '%21')
.replace(/\*/g, '%2A')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29');
}
// Nonce generation
const NONCE_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function generateNonce(length = 32): string {
const bytes = crypto.randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return result;
}
// Timestamp
function generateTimestamp(): string {
return Math.floor(Date.now() / 1000).toString();
}
// Parse query string from URL
// Escapes bare '+' before delegating to URLSearchParams, because
// URLSearchParams decodes '+' as space (HTML form convention) but
// RFC 5849 treats '+' as a literal character.
export function parseQueryParams(url: string): Array<[string, string]> {
try {
const parsed = new URL(url);
if (!parsed.search) return [];
// Escape bare '+' so URLSearchParams preserves them as literal '+'
const safeSearch = parsed.search.slice(1).replace(/\+/g, '%2B');
const searchParams = new URLSearchParams(safeSearch);
const pairs: Array<[string, string]> = [];
searchParams.forEach((value, key) => {
pairs.push([key, value]);
});
return pairs;
} catch {
return [];
}
}
// Base URL normalized per RFC 5849 §3.4.1.2
// Lowercase scheme/host, strip default ports, remove query string and fragment
export function getBaseUrl(url: string): string {
try {
const parsed = new URL(url);
const scheme = parsed.protocol.toLowerCase();
const host = parsed.hostname.toLowerCase();
const port = parsed.port;
// Omit default ports (80 for http, 443 for https)
const includePort
= port && !((scheme === 'http:' && port === '80') || (scheme === 'https:' && port === '443'));
return `${scheme}//${host}${includePort ? ':' + port : ''}${parsed.pathname}`;
} catch {
// Fallback for non-standard URLs: just strip query string and fragment
return url.split('?')[0].split('#')[0];
}
}
// Build the normalized parameter string (RFC 5849 §3.4.1.3.2)
export function buildParameterString(
oauthParams: Record<string, string>,
queryParams: Array<[string, string]>
): string {
const collected: Array<[string, string]> = [];
for (const [k, v] of Object.entries(oauthParams)) {
collected.push([percentEncode(k), percentEncode(v)]);
}
for (const [k, v] of queryParams) {
collected.push([percentEncode(k), percentEncode(v)]);
}
collected.sort((a, b) => {
if (a[0] < b[0]) return -1;
if (a[0] > b[0]) return 1;
if (a[1] < b[1]) return -1;
if (a[1] > b[1]) return 1;
return 0;
});
return collected.map(([k, v]) => `${k}=${v}`).join('&');
}
// Signature Base String (RFC 5849 §3.4.1)
export function buildBaseString(method: string, baseUrl: string, parameterString: string): string {
return `${method.toUpperCase()}&${percentEncode(baseUrl)}&${percentEncode(parameterString)}`;
}
// Signing Key (RFC 5849 §3.4.2)
export function buildSigningKey(consumerSecret: string, tokenSecret: string): string {
return `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret)}`;
}
// Default hash function
function defaultHashFunction(
baseString: string,
key: string,
method: SignatureMethod,
privateKey?: string
): string {
switch (method) {
case 'PLAINTEXT':
return key;
case 'RSA-SHA1':
case 'RSA-SHA256':
case 'RSA-SHA512': {
if (!privateKey) {
throw new Error(`Private key is required for ${method} signature method`);
}
const algoMap: Record<string, string> = {
'RSA-SHA1': 'RSA-SHA1',
'RSA-SHA256': 'RSA-SHA256',
'RSA-SHA512': 'RSA-SHA512'
};
const signer = crypto.createSign(algoMap[method]);
signer.update(baseString);
return signer.sign(privateKey, 'base64');
}
case 'HMAC-SHA512':
return crypto.createHmac('sha512', key).update(baseString).digest('base64');
case 'HMAC-SHA256':
return crypto.createHmac('sha256', key).update(baseString).digest('base64');
case 'HMAC-SHA1':
return crypto.createHmac('sha1', key).update(baseString).digest('base64');
default:
throw new Error(`Unsupported OAuth1 signature method: ${method}`);
}
}
// Body Hash (draft-eaton-oauth-bodyhash-00)
// https://datatracker.ietf.org/doc/id/draft-eaton-oauth-bodyhash-00.html
export function computeBodyHash(body: string, signatureMethod: SignatureMethod): string {
const algoMap: Record<string, string> = {
'HMAC-SHA512': 'sha512',
'HMAC-SHA256': 'sha256',
'RSA-SHA512': 'sha512',
'RSA-SHA256': 'sha256'
};
const algo = algoMap[signatureMethod] || 'sha1';
return crypto.createHash(algo).update(body).digest('base64');
}
/**
* OAuth 1.0 authorization library (RFC 5849).
*
* API mirrors the oauth-1.0a npm package:
* - `authorize(requestData, token?)` - generates signed OAuth params
* - `toHeader(oauthData)` - formats params as an Authorization header
*
* Implements signing from scratch using Node.js crypto.
* Supports HMAC-SHA1, HMAC-SHA256, HMAC-SHA512, RSA-SHA1, RSA-SHA256, RSA-SHA512, and PLAINTEXT.
*/
export function createOAuth1Authorizer(config: OAuth1Config) {
const {
consumer,
signature_method: signatureMethod = 'HMAC-SHA1',
version = '1.0',
realm,
private_key: privateKey,
hash_function: customHashFunction
} = config;
function authorize(
requestData: OAuth1RequestData,
token?: OAuth1Token,
callbackUrl?: string,
verifier?: string,
overrides?: { timestamp?: string; nonce?: string }
): OAuth1AuthData {
const oauthParams: Record<string, string> = {
oauth_consumer_key: consumer.key,
oauth_nonce: overrides?.nonce || generateNonce(),
oauth_signature_method: signatureMethod,
oauth_timestamp: overrides?.timestamp || generateTimestamp(),
oauth_version: version || '1.0'
};
if (token?.key) {
oauthParams.oauth_token = token.key;
}
// RFC 5849 §2.1: oauth_callback is REQUIRED in the Temporary Credentials Request
if (callbackUrl) {
oauthParams.oauth_callback = callbackUrl;
}
// RFC 5849 §2.3: oauth_verifier is REQUIRED in the Token Credentials Request
if (verifier) {
oauthParams.oauth_verifier = verifier;
}
// Separate oauth_* extension params (e.g. oauth_body_hash) from body params
// oauth_* params go into oauthParams (included in Authorization header)
// Body params are kept as pairs (preserving duplicates per RFC 5849 §3.4.1.3.2)
const bodyParams: Array<[string, string]> = [];
if (requestData.data) {
for (const [k, v] of requestData.data) {
if (k.startsWith('oauth_')) {
oauthParams[k] = v;
} else {
bodyParams.push([k, v]);
}
}
}
const extraParams: Array<[string, string]> = [
...parseQueryParams(requestData.url),
...bodyParams
];
const parameterString = buildParameterString(oauthParams, extraParams);
const baseString = buildBaseString(requestData.method, getBaseUrl(requestData.url), parameterString);
// Build signing key & sign
const tokenSecret = token?.secret || '';
const signingKey = buildSigningKey(consumer.secret, tokenSecret);
if (customHashFunction) {
oauthParams.oauth_signature = customHashFunction(baseString, signingKey);
} else {
oauthParams.oauth_signature = defaultHashFunction(baseString, signingKey, signatureMethod, privateKey);
}
return oauthParams as OAuth1AuthData;
}
function toHeader(oauthData: OAuth1AuthData): { Authorization: string } {
let header = 'OAuth ';
if (realm) {
header += `realm="${realm.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}", `;
}
const parts: string[] = [];
const sortedKeys = Object.keys(oauthData).sort();
for (const key of sortedKeys) {
if (!key.startsWith('oauth_')) continue;
parts.push(`${percentEncode(key)}="${percentEncode(oauthData[key]!)}"`);
}
header += parts.join(', ');
return { Authorization: header };
}
return { authorize, toHeader };
}
/**
* Applies OAuth1 signing to a request object in-place.
*
* Handles the full flow: authorizer creation, body hash, signing with
* optional timestamp/nonce overrides, and placing params in header,
* query string, or body per RFC 5849.
*
* Shared by bruno-electron and bruno-cli to avoid duplication.
*/
export function applyOAuth1ToRequest(request: {
url: string;
method: string;
headers: Record<string, string>;
data?: any;
oauth1config: {
consumerKey: string;
consumerSecret: string;
accessToken?: string;
accessTokenSecret?: string;
callbackUrl?: string;
verifier?: string;
signatureMethod?: string;
privateKey?: string;
privateKeyType?: string;
timestamp?: string;
nonce?: string;
version?: string;
realm?: string;
placement?: string;
includeBodyHash?: boolean;
};
}, collectionPath?: string): void {
const {
consumerKey, consumerSecret, accessToken, accessTokenSecret,
callbackUrl, verifier, signatureMethod, privateKey, privateKeyType, timestamp, nonce,
version, realm, placement, includeBodyHash
} = request.oauth1config;
// Clear credentials from the request object before any operation that could throw
delete (request as any).oauth1config;
// Resolve private key: read from file if privateKeyType is 'file', otherwise use as-is
let resolvedPrivateKey: string | undefined;
if (privateKey) {
if (privateKeyType === 'file') {
let filePath = privateKey;
if (collectionPath && !nodePath.isAbsolute(filePath)) {
filePath = nodePath.join(collectionPath, filePath);
}
resolvedPrivateKey = readPrivateKeyFile(filePath);
} else {
resolvedPrivateKey = privateKey.replace(/\\n/g, '\n');
}
}
const authorizer = createOAuth1Authorizer({
consumer: { key: consumerKey, secret: consumerSecret },
signature_method: (signatureMethod || 'HMAC-SHA1') as SignatureMethod,
version: version || '1.0',
realm: realm || undefined,
private_key: resolvedPrivateKey
});
const requestData: OAuth1RequestData = {
url: request.url,
method: request.method
};
// Determine if body is form-encoded
const ctKey = Object.keys(request.headers).find((name) => name.toLowerCase() === 'content-type');
const ctValue = (ctKey ? request.headers[ctKey] : '') || '';
const isFormUrlEncoded = ctValue.startsWith('application/x-www-form-urlencoded');
const method = request.method.toUpperCase();
const hasBody = method !== 'GET' && method !== 'HEAD';
// RFC 5849 §3.4.1.3.1: form-encoded body params MUST be included in the signature base string.
// When placement is 'body', include body params even for GET/HEAD since Bruno sends the body regardless.
const dataPairs: Array<[string, string]> = [];
const includeBodyInSignature = placement === 'body' || hasBody;
if (includeBodyInSignature && isFormUrlEncoded && request.data) {
const bodyStr = typeof request.data === 'string' ? request.data : '';
if (bodyStr) {
new URLSearchParams(bodyStr).forEach((v, k) => {
dataPairs.push([k, v]);
});
}
}
// draft-eaton-oauth-bodyhash-00 §3.2: MUST NOT include oauth_body_hash for form-encoded bodies;
// if no entity body, hash over the empty string
if (includeBodyHash && !isFormUrlEncoded) {
const bodyStr = request.data
? (typeof request.data === 'string' ? request.data : JSON.stringify(request.data))
: '';
const bodyHash = computeBodyHash(bodyStr, (signatureMethod || 'HMAC-SHA1') as SignatureMethod);
dataPairs.push(['oauth_body_hash', bodyHash]);
}
if (dataPairs.length > 0) {
requestData.data = dataPairs;
}
const token = accessToken ? { key: accessToken, secret: accessTokenSecret || '' } : undefined;
const overrides: { timestamp?: string; nonce?: string } = {};
if (timestamp) overrides.timestamp = timestamp;
if (nonce) overrides.nonce = nonce;
const oauthData = authorizer.authorize(requestData, token, callbackUrl || undefined, verifier || undefined, overrides);
switch (placement || 'header') {
case 'header':
request.headers['Authorization'] = authorizer.toHeader(oauthData).Authorization;
break;
case 'query': {
const url = new URL(request.url);
Object.entries(oauthData).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
request.url = url.toString();
break;
}
case 'body': {
const params = new URLSearchParams(isFormUrlEncoded ? request.data : '');
Object.entries(oauthData).forEach(([key, value]) => {
if (value !== undefined) params.set(key, value);
});
request.data = params.toString();
if (!isFormUrlEncoded) {
if (ctKey) {
request.headers[ctKey] = 'application/x-www-form-urlencoded';
} else {
request.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
break;
}
}
}

View File

@@ -1,4 +1,4 @@
export { addDigestInterceptor, getOAuth2Token } from './auth';
export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './auth';
export { GrpcClient, generateGrpcSampleMessage } from './grpc';
export { WsClient } from './ws/ws-client';
export { default as cookies } from './cookies';

View File

@@ -38,6 +38,24 @@ export interface AuthApiKey {
placement?: 'header' | 'queryparams' | null;
}
export interface AuthOauth1 {
consumerKey?: string | null;
consumerSecret?: string | null;
accessToken?: string | null;
accessTokenSecret?: string | null;
callbackUrl?: string | null;
verifier?: string | null;
signatureMethod?: 'HMAC-SHA1' | 'HMAC-SHA256' | 'HMAC-SHA512' | 'RSA-SHA1' | 'RSA-SHA256' | 'RSA-SHA512' | 'PLAINTEXT' | null;
privateKey?: string | null;
privateKeyType?: 'file' | 'text' | null;
timestamp?: string | null;
nonce?: string | null;
version?: string | null;
realm?: string | null;
placement?: 'header' | 'query' | 'body' | null;
includeBodyHash?: boolean | null;
}
export type OAuthGrantType
= | 'client_credentials'
| 'password'
@@ -89,6 +107,7 @@ export type AuthMode
| 'bearer'
| 'digest'
| 'ntlm'
| 'oauth1'
| 'oauth2'
| 'wsse'
| 'apikey';
@@ -100,6 +119,7 @@ export interface Auth {
bearer?: AuthBearer | null;
digest?: AuthDigest | null;
ntlm?: AuthNTLM | null;
oauth1?: AuthOauth1 | null;
oauth2?: OAuth2 | null;
wsse?: AuthWsse | null;
apikey?: AuthApiKey | null;

View File

@@ -199,6 +199,26 @@ const authApiKeySchema = Yup.object({
.noUnknown(true)
.strict();
const authOAuth1Schema = Yup.object({
consumerKey: Yup.string().nullable(),
consumerSecret: Yup.string().nullable(),
accessToken: Yup.string().nullable(),
accessTokenSecret: Yup.string().nullable(),
callbackUrl: Yup.string().nullable(),
verifier: Yup.string().nullable(),
signatureMethod: Yup.string().oneOf(['HMAC-SHA1', 'HMAC-SHA256', 'HMAC-SHA512', 'RSA-SHA1', 'RSA-SHA256', 'RSA-SHA512', 'PLAINTEXT']).nullable(),
privateKey: Yup.string().nullable(),
privateKeyType: Yup.string().oneOf(['file', 'text']).nullable(),
timestamp: Yup.string().nullable(),
nonce: Yup.string().nullable(),
version: Yup.string().nullable(),
realm: Yup.string().nullable(),
placement: Yup.string().oneOf(['header', 'query', 'body']).nullable(),
includeBodyHash: Yup.boolean().nullable()
})
.noUnknown(true)
.strict();
const oauth2AuthorizationAdditionalParametersSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
@@ -337,13 +357,14 @@ const oauth2Schema = Yup.object({
const authSchema = Yup.object({
mode: Yup.string()
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth2', 'wsse', 'apikey'])
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth1', 'oauth2', 'wsse', 'apikey'])
.required('mode is required'),
awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable(),
ntlm: authNTLMSchema.nullable(),
digest: authDigestSchema.nullable(),
oauth1: authOAuth1Schema.nullable(),
oauth2: oauth2Schema.nullable(),
wsse: authWsseSchema.nullable(),
apikey: authApiKeySchema.nullable()

View File

@@ -8,7 +8,9 @@ const authCookie = require('./cookie');
const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials');
const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode');
const authOAuth2ClientCredentials = require('./oauth2/clientCredentials');
const authOAuth1 = require('./oauth1');
router.use('/oauth1', authOAuth1);
router.use('/oauth2/password_credentials', authOAuth2PasswordCredentials);
router.use('/oauth2/authorization_code', authOAuth2AuthorizationCode);
router.use('/oauth2/client_credentials', authOAuth2ClientCredentials);

View File

@@ -0,0 +1,559 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
// ─── Known Test Credentials ────────────────────────────────────────────────────
const consumers = [
{
key: 'consumer_key_1',
secret: 'consumer_secret_1'
}
];
// Pre-provisioned access token for simple one-legged testing
const accessTokens = [
{
token: 'access_token_1',
secret: 'token_secret_1',
consumerKey: 'consumer_key_1'
}
];
// In-memory stores for the three-legged flow
const requestTokens = [];
const usedNonces = new Map(); // key: `${consumerKey}:${nonce}:${timestamp}`
// RSA key pair for RSA-SHA* signing/verification
const TEST_RSA_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCf/ioOwb6uGm01
WjL89sBOKmvMuwyGW33w+ud3eV5dWbHZbJTgCHMtjUVqGE4sVPnX8wfKgb00hVGZ
ZhbRsqquUMJL+7RHZA1G3ZcC2WPmrUvIpi13W7F/+wizRjDrutgK0oc4L7zUPhgE
+d+Qe6JHvFL376hqkD9Z6vd9jVNRDv7D2uJcG+Uc7ePM6yLzQeJILnfvSwxna89G
6YxfbYNjf/VKAcRM6UAR8aRGm/A9Qmdwsy08iRzlM0K5lid7Ath7ADJ+D6ezUIhl
qiI6sVrZdpsFXzuvcxjgAts54XoQsLCDZudTmzMs4BftswULCQFdj6c2oye9QKsu
EoZ0CvwNAgMBAAECggEAA1kIJu2QQIhhB0rEjQfaF5309NW9JK/pag91+claW3hd
q6papc1yIN6MjfRwPlE7i8npZygL03uEAkJhRoYHOEU3AOwFZluw7hiuPkBaQiDC
Ld1RpOZlnRidoqgHV1y+LzZ0ieJwgGhu4ZEbnSRZIvMihqRHJo5aJQGGUuQ60r6w
Z5N6j7GGCV14oGck6+0k/NhYrkhpbyl+AHPZeQ20L9ZaSpl+GD3RUgvx4nOhN1Fx
agovDCKwmRPSpuuBw6Wun1hKC9MuuL89CCy2yZ+MrjQmQJ/onKZKR2fc1I5g2JRu
z7xObQq/tAXIHKM27YYY5J3NcGtsy9tLpdtFDle/wQKBgQDfBaBURNs/ccVMMXSZ
T5lOo337Rv3bGFnoLwURUZrjJNGrHqLzeZvTNt0sXQX6Jbdeb5qnC/icng+QDEod
9hRVDz1rN+2YqG6AMvrX5dQSK1PmkwVHLles4sX7DsZaiKNYFFbXc9rt7kXBwDoE
LXYidqUIqZiOjMqCyNlfyHHnRQKBgQC3ppq7TCEWIfqLzsfOoqaKD/fDOrQLLmor
7RvAdGa+EyVeB1G+NQO00KN6U9W+SNPz0cEUYUZQiAaAghUGkNurrS1shr5k7aTX
pXpXtaA+xSEAd6w8lTl9mAfwZMBCcsfjyjPf1RPjZ6Tj2fdHqnsllSU5843LOqZK
CBXiitdKKQKBgQCVNEloN0zLLE1HxUpxiwxQzRZqtrr9ClST/mkQhhzuW+Kd7fgs
la5HZ0we8vkdun/sARRhL6Qa+7ADugUX+Frv8SsxARDG8eBDilfBevQfV7dg6fk8
/ucPNgQoC2Fujj1hnvHeYJcWWTN4BSeLRfLj6aZNnlD/BXgyeTbcWtjBVQKBgBhG
npd5hboePbczWzgWSftgBvk4jkoYFZK+4fc7q8UeVMcsIoMJEPdayPFHma5whAvr
wyEFhrzobiuYhlz60v7LgoChAxPmUe7rgdOMP6Vse2NLbmoHs7TFXu9I8h0WfRPA
S8EfsmRR8/rmeghwIZ0jLOuPJUQi+Y45qWLrxW+ZAoGAMXYhio9y93M0nRqjiiCR
YibnhpZxvrNiLPxNiUi/WxcWEvulpmbKxLUhiWJB1ZmRTiGYlnclQXUuRyaOQNTo
5TVAaNzDXayWVbxhx3Lb8NUV+QNUJEJOgjq4+NYw8fUZCr7T64pGGM4DJHPuHCBo
dJv7UByPuMKBIOYpy3Z+iWs=
-----END PRIVATE KEY-----
`;
const TEST_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn/4qDsG+rhptNVoy/PbA
TiprzLsMhlt98Prnd3leXVmx2WyU4AhzLY1FahhOLFT51/MHyoG9NIVRmWYW0bKq
rlDCS/u0R2QNRt2XAtlj5q1LyKYtd1uxf/sIs0Yw67rYCtKHOC+81D4YBPnfkHui
R7xS9++oapA/Wer3fY1TUQ7+w9riXBvlHO3jzOsi80HiSC5370sMZ2vPRumMX22D
Y3/1SgHETOlAEfGkRpvwPUJncLMtPIkc5TNCuZYnewLYewAyfg+ns1CIZaoiOrFa
2XabBV87r3MY4ALbOeF6ELCwg2bnU5szLOAX7bMFCwkBXY+nNqMnvUCrLhKGdAr8
DQIDAQAB
-----END PUBLIC KEY-----`;
// ─── RFC 5849 Helpers ───────────────────────────────────────────────────────────
function percentEncode(str) {
return encodeURIComponent(str)
.replace(/!/g, '%21')
.replace(/\*/g, '%2A')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29');
}
function percentDecode(str) {
return decodeURIComponent(str);
}
function generateUniqueString() {
return crypto.randomBytes(16).toString('hex');
}
// RFC 5849 §3.4.1.2 - Base URL normalization
function getBaseUrl(req) {
const protocol = req.protocol;
const host = req.hostname;
const port = req.socket.localPort;
const path = req.baseUrl + req.path;
const includePort = port && !(
(protocol === 'http' && port === 80)
|| (protocol === 'https' && port === 443)
);
return `${protocol}://${host}${includePort ? ':' + port : ''}${path}`;
}
// Parse OAuth Authorization header
function parseOAuthHeader(header) {
if (!header || !header.startsWith('OAuth ')) return null;
const params = {};
const paramStr = header.slice(6); // Remove 'OAuth '
// Match key="value" pairs, handling commas within values
const regex = /(\w+)="([^"]*)"/g;
let match;
while ((match = regex.exec(paramStr)) !== null) {
const key = percentDecode(match[1]);
const value = percentDecode(match[2]);
if (key !== 'realm') {
params[key] = value;
}
}
return params;
}
// Collect OAuth params from all sources (header, query, body)
function collectOAuthParams(req) {
// 1. Try Authorization header first
const authHeader = req.headers['authorization'];
const headerParams = parseOAuthHeader(authHeader);
if (headerParams && headerParams.oauth_consumer_key) {
return { params: headerParams, source: 'header' };
}
// 2. Try query params
const queryParams = {};
let foundInQuery = false;
for (const [key, value] of Object.entries(req.query || {})) {
if (key.startsWith('oauth_')) {
queryParams[key] = value;
foundInQuery = true;
}
}
if (foundInQuery && queryParams.oauth_consumer_key) {
return { params: queryParams, source: 'queryparams' };
}
// 3. Try body params (only for application/x-www-form-urlencoded)
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/x-www-form-urlencoded') && req.body) {
const bodyParams = {};
let foundInBody = false;
for (const [key, value] of Object.entries(req.body)) {
if (key.startsWith('oauth_')) {
bodyParams[key] = value;
foundInBody = true;
}
}
if (foundInBody && bodyParams.oauth_consumer_key) {
return { params: bodyParams, source: 'body' };
}
}
return { params: null, source: null };
}
// Collect all non-oauth parameters from query and body for signature base string
function collectRequestParams(req, oauthParams, oauthSource) {
const collected = [];
// Include oauth params (except oauth_signature)
for (const [key, value] of Object.entries(oauthParams)) {
if (key !== 'oauth_signature') {
collected.push([percentEncode(key), percentEncode(value)]);
}
}
// Include query params (skip oauth_* — already collected from oauthParams above)
// RFC 5849 §3.5: each protocol parameter MUST use one and only one transmission method
for (const [key, value] of Object.entries(req.query || {})) {
if (key.startsWith('oauth_')) continue;
if (Array.isArray(value)) {
for (const v of value) {
collected.push([percentEncode(key), percentEncode(v)]);
}
} else {
collected.push([percentEncode(key), percentEncode(value)]);
}
}
// Include body params for form-urlencoded (skip oauth_* — already collected from oauthParams above)
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/x-www-form-urlencoded') && req.body) {
for (const [key, value] of Object.entries(req.body)) {
if (key.startsWith('oauth_')) continue;
if (Array.isArray(value)) {
for (const v of value) {
collected.push([percentEncode(key), percentEncode(v)]);
}
} else {
collected.push([percentEncode(key), percentEncode(value)]);
}
}
}
// Sort per RFC 5849 §3.4.1.3.2
collected.sort((a, b) => {
if (a[0] < b[0]) return -1;
if (a[0] > b[0]) return 1;
if (a[1] < b[1]) return -1;
if (a[1] > b[1]) return 1;
return 0;
});
return collected.map(([k, v]) => `${k}=${v}`).join('&');
}
// Build signature base string (RFC 5849 §3.4.1)
function buildBaseString(method, baseUrl, parameterString) {
return `${method.toUpperCase()}&${percentEncode(baseUrl)}&${percentEncode(parameterString)}`;
}
// Verify signature
function timingSafeCompare(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
function verifySignature(baseString, signature, signatureMethod, consumerSecret, tokenSecret) {
const signingKey = `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret || '')}`;
switch (signatureMethod) {
case 'HMAC-SHA1': {
const expected = crypto.createHmac('sha1', signingKey).update(baseString).digest('base64');
return timingSafeCompare(signature, expected);
}
case 'HMAC-SHA256': {
const expected = crypto.createHmac('sha256', signingKey).update(baseString).digest('base64');
return timingSafeCompare(signature, expected);
}
case 'HMAC-SHA512': {
const expected = crypto.createHmac('sha512', signingKey).update(baseString).digest('base64');
return timingSafeCompare(signature, expected);
}
case 'RSA-SHA1':
case 'RSA-SHA256':
case 'RSA-SHA512': {
const algoMap = { 'RSA-SHA1': 'RSA-SHA1', 'RSA-SHA256': 'RSA-SHA256', 'RSA-SHA512': 'RSA-SHA512' };
const verifier = crypto.createVerify(algoMap[signatureMethod]);
verifier.update(baseString);
return verifier.verify(TEST_RSA_PUBLIC_KEY, signature, 'base64');
}
case 'PLAINTEXT': {
return timingSafeCompare(signature, signingKey);
}
default:
return false;
}
}
// Check nonce uniqueness (prevents replay attacks)
function checkNonce(consumerKey, nonce, timestamp) {
const key = `${consumerKey}:${nonce}:${timestamp}`;
if (usedNonces.has(key)) return false;
usedNonces.set(key, Date.now());
// Clean up old nonces (older than 10 minutes)
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
for (const [k, v] of usedNonces.entries()) {
if (v < tenMinutesAgo) usedNonces.delete(k);
}
return true;
}
// ─── OAuth 1.0 Signature Verification Middleware ────────────────────────────────
function verifyOAuth1Signature(getTokenSecret) {
return (req, res, next) => {
const { params: oauthParams, source: oauthSource } = collectOAuthParams(req);
if (!oauthParams) {
return res.status(401).json({ error: 'Missing OAuth parameters' });
}
const {
oauth_consumer_key,
oauth_signature,
oauth_signature_method,
oauth_nonce,
oauth_timestamp,
oauth_version
} = oauthParams;
// Validate required params
if (!oauth_consumer_key || !oauth_signature || !oauth_signature_method) {
return res.status(401).json({ error: 'Missing required OAuth parameters' });
}
// Validate version if present
if (oauth_version && oauth_version !== '1.0') {
return res.status(401).json({ error: 'Unsupported OAuth version' });
}
// Look up consumer
const consumer = consumers.find((c) => c.key === oauth_consumer_key);
if (!consumer) {
return res.status(401).json({ error: 'Unknown consumer' });
}
// Check nonce uniqueness (skip for PLAINTEXT which doesn't use nonce/timestamp)
if (oauth_signature_method !== 'PLAINTEXT') {
if (!oauth_nonce || !oauth_timestamp) {
return res.status(401).json({ error: 'Missing nonce or timestamp' });
}
if (!checkNonce(oauth_consumer_key, oauth_nonce, oauth_timestamp)) {
return res.status(401).json({ error: 'Nonce already used' });
}
}
// Get token secret from callback
const tokenSecret = getTokenSecret(oauthParams, req);
// Build base string and verify signature
const baseUrl = getBaseUrl(req);
const parameterString = collectRequestParams(req, oauthParams, oauthSource);
const baseString = buildBaseString(req.method, baseUrl, parameterString);
const isValid = verifySignature(
baseString,
oauth_signature,
oauth_signature_method,
consumer.secret,
tokenSecret
);
if (!isValid) {
return res.status(401).json({
error: 'Invalid signature',
debug: {
baseString,
baseUrl,
parameterString,
method: req.method
}
});
}
req.oauthConsumer = consumer;
req.oauthParams = oauthParams;
next();
};
}
// ─── Routes ─────────────────────────────────────────────────────────────────────
// 1. Request Token (Temporary Credentials) - RFC 5849 §2.1
router.post('/request_token',
verifyOAuth1Signature((oauthParams) => {
// No token secret for request token requests
return '';
}),
(req, res) => {
const callbackUrl = req.oauthParams.oauth_callback;
// RFC 5849 §2.1: oauth_callback is REQUIRED
if (!callbackUrl) {
return res.status(400).json({ error: 'Missing required oauth_callback parameter' });
}
// RFC 5849 §2.1: must be an absolute URI or "oob" (case sensitive)
if (callbackUrl !== 'oob') {
try {
const parsed = new URL(callbackUrl);
if (!parsed.protocol.startsWith('http')) {
return res.status(400).json({ error: 'oauth_callback must be an absolute HTTP(S) URI or "oob"' });
}
} catch {
return res.status(400).json({ error: 'oauth_callback must be a valid absolute URI or "oob"' });
}
}
const requestToken = {
token: 'rt_' + generateUniqueString(),
secret: 'rts_' + generateUniqueString(),
consumerKey: req.oauthConsumer.key,
callbackUrl,
verifier: null,
authorized: false
};
requestTokens.push(requestToken);
// Return as form-encoded per spec
res.type('application/x-www-form-urlencoded');
res.send(
`oauth_token=${percentEncode(requestToken.token)}`
+ `&oauth_token_secret=${percentEncode(requestToken.secret)}`
+ `&oauth_callback_confirmed=true`
);
}
);
// 2. Resource Owner Authorization - RFC 5849 §2.2
router.get('/authorize', (req, res) => {
const { oauth_token } = req.query;
if (!oauth_token) {
return res.status(400).json({ error: 'Missing oauth_token parameter' });
}
const storedToken = requestTokens.find((t) => t.token === oauth_token);
if (!storedToken) {
return res.status(400).json({ error: 'Invalid request token' });
}
// Auto-authorize and redirect (simplified for testing)
const verifier = generateUniqueString();
storedToken.verifier = verifier;
storedToken.authorized = true;
if (storedToken.callbackUrl === 'oob') {
// Out-of-band: display the verifier
return res.send(`
<html>
<body>
<h1>Authorization Successful</h1>
<p>Your verification code is: <code>${verifier}</code></p>
</body>
</html>
`);
}
// Redirect back to consumer with oauth_token and oauth_verifier
const callbackUrl = new URL(storedToken.callbackUrl);
callbackUrl.searchParams.set('oauth_token', storedToken.token);
callbackUrl.searchParams.set('oauth_verifier', verifier);
res.redirect(callbackUrl.toString());
});
// 3. Access Token (Token Credentials) - RFC 5849 §2.3
router.post('/access_token',
verifyOAuth1Signature((oauthParams) => {
// Token secret is the request token's secret
const rt = requestTokens.find((t) => t.token === oauthParams.oauth_token);
return rt ? rt.secret : '';
}),
(req, res) => {
const { oauth_token, oauth_verifier } = req.oauthParams;
// RFC 5849 §2.3: oauth_token and oauth_verifier are REQUIRED
if (!oauth_token) {
return res.status(400).json({ error: 'Missing required oauth_token parameter' });
}
if (!oauth_verifier) {
return res.status(400).json({ error: 'Missing required oauth_verifier parameter' });
}
const storedToken = requestTokens.find((t) => t.token === oauth_token);
if (!storedToken) {
return res.status(401).json({ error: 'Invalid request token' });
}
if (!storedToken.authorized) {
return res.status(401).json({ error: 'Request token not authorized' });
}
if (storedToken.verifier !== oauth_verifier) {
return res.status(401).json({ error: 'Invalid verifier' });
}
// Issue access token
const accessToken = {
token: 'at_' + generateUniqueString(),
secret: 'ats_' + generateUniqueString(),
consumerKey: req.oauthConsumer.key
};
accessTokens.push(accessToken);
// Invalidate request token
const idx = requestTokens.indexOf(storedToken);
if (idx !== -1) requestTokens.splice(idx, 1);
// Return as form-encoded per spec
res.type('application/x-www-form-urlencoded');
res.send(
`oauth_token=${percentEncode(accessToken.token)}`
+ `&oauth_token_secret=${percentEncode(accessToken.secret)}`
);
}
);
// 4. Protected Resource - verifies signed requests with access token
router.get('/resource',
verifyOAuth1Signature((oauthParams) => {
const at = accessTokens.find(
(t) => t.token === oauthParams.oauth_token
);
return at ? at.secret : '';
}),
(req, res) => {
res.json({
resource: {
name: 'oauth1-test-resource',
email: 'oauth1@example.com'
}
});
}
);
router.post('/resource',
verifyOAuth1Signature((oauthParams) => {
const at = accessTokens.find(
(t) => t.token === oauthParams.oauth_token
);
return at ? at.secret : '';
}),
(req, res) => {
res.json({
resource: {
name: 'oauth1-test-resource',
email: 'oauth1@example.com'
}
});
}
);
// 5. Callback (Consumer-side) - RFC 5849 §2.2
// Receives the redirect from /authorize with oauth_token and oauth_verifier.
// The consumer then exchanges these for token credentials via /access_token.
router.get('/callback', (req, res) => {
const { oauth_token, oauth_verifier } = req.query;
if (!oauth_token || !oauth_verifier) {
return res.status(400).json({ error: 'Missing oauth_token or oauth_verifier' });
}
// Verify the request token exists and is authorized
const storedToken = requestTokens.find((t) => t.token === oauth_token);
if (!storedToken) {
return res.status(400).json({ error: 'Unknown request token' });
}
if (!storedToken.authorized) {
return res.status(400).json({ error: 'Request token not yet authorized' });
}
if (storedToken.verifier !== oauth_verifier) {
return res.status(400).json({ error: 'Invalid verifier' });
}
res.json({
oauth_token,
oauth_verifier,
message: 'Callback received. Exchange these for token credentials via POST /access_token.'
});
});
module.exports = router;
module.exports.TEST_RSA_PRIVATE_KEY = TEST_RSA_PRIVATE_KEY;