Files
bruno/packages/bruno-tests/src/auth/oauth1/index.js
lohit 95de14adcb 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
2026-03-27 18:59:42 +05:30

560 lines
18 KiB
JavaScript

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;