mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', '')}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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()}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
850
packages/bruno-lang/v2/tests/oauth1.spec.js
Normal file
850
packages/bruno-lang/v2/tests/oauth1.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
468
packages/bruno-requests/src/auth/oauth1-request-authorization.ts
Normal file
468
packages/bruno-requests/src/auth/oauth1-request-authorization.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
559
packages/bruno-tests/src/auth/oauth1/index.js
Normal file
559
packages/bruno-tests/src/auth/oauth1/index.js
Normal 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;
|
||||
Reference in New Issue
Block a user