mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 14:35:03 +00:00
feat(auth): akamai auth integration (#8199)
This commit is contained in:
214
packages/bruno-requests/src/auth/edgegrid-helper.js
Normal file
214
packages/bruno-requests/src/auth/edgegrid-helper.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
/**
|
||||
* Akamai EdgeGrid Authentication Helper
|
||||
* Implements the EG1-HMAC-SHA256 scheme exactly as Akamai's official client library
|
||||
* (https://github.com/akamai/AkamaiOPEN-edgegrid-node) so signatures validate against
|
||||
* the real Akamai gateway.
|
||||
* Spec: https://techdocs.akamai.com/developer/docs/authenticate-with-edgegrid
|
||||
*/
|
||||
|
||||
const MAX_BODY_SIZE_DEFAULT = 131072; // 128 KB
|
||||
|
||||
function isStrPresent(str) {
|
||||
return str && str.trim() !== '' && str.trim() !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UTC timestamp in EdgeGrid format: YYYYMMDDTHH:MM:SS+0000
|
||||
* (date part has no separators, time part keeps colons, fixed +0000 offset).
|
||||
* @returns {string}
|
||||
*/
|
||||
function makeEdgeGridTimestamp() {
|
||||
const [datePart, timePart] = new Date().toISOString().split('T');
|
||||
const date = datePart.replace(/-/g, '');
|
||||
const time = timePart.replace(/\.\d+Z$/, '');
|
||||
return `${date}T${time}+0000`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random nonce (UUID v4)
|
||||
* @returns {string}
|
||||
*/
|
||||
function makeEdgeGridNonce() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encoded HMAC-SHA256. Note the result is a base64 STRING; when used as the
|
||||
* signing key it is passed (as a string) as the key of the next HMAC — matching Akamai.
|
||||
* @param {string|Buffer} data
|
||||
* @param {string} key
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64HmacSha256(data, key) {
|
||||
return crypto.createHmac('sha256', key).update(data).digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encoded SHA256 hash.
|
||||
* @param {string|Buffer} data
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Sha256(data) {
|
||||
return crypto.createHash('sha256').update(data).digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonicalized headers segment from the comma-separated list of header NAMES
|
||||
* the user asked to sign, pulling the actual values off the outgoing request.
|
||||
* Format per header: `name(lowercase):value(trimmed, internal whitespace collapsed)`,
|
||||
* joined by a tab — matching Akamai's canonicalizeHeaders().
|
||||
* @param {string} headersToSign - comma-separated header names
|
||||
* @param {Object} requestHeaders - outgoing request headers ({ name: value })
|
||||
* @returns {string}
|
||||
*/
|
||||
function canonicalizeHeaders(headersToSign, requestHeaders = {}) {
|
||||
if (!isStrPresent(headersToSign)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// case-insensitive lookup of the actual request header values
|
||||
const lookup = {};
|
||||
Object.keys(requestHeaders || {}).forEach((name) => {
|
||||
lookup[name.toLowerCase()] = requestHeaders[name];
|
||||
});
|
||||
|
||||
return headersToSign
|
||||
.split(',')
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter((name) => name.length > 0)
|
||||
// Akamai only signs headers that are actually present on the request (config order preserved);
|
||||
// names not present are skipped, not emitted as empty values.
|
||||
.filter((name) => Object.prototype.hasOwnProperty.call(lookup, name))
|
||||
.map((name) => `${name}:${String(lookup[name]).trim().replace(/\s+/g, ' ')}`)
|
||||
.join('\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the request body content hash. Akamai hashes the body for POST requests only,
|
||||
* over the exact bytes that are sent on the wire (no re-serialization), truncated to
|
||||
* maxBodySize before hashing.
|
||||
* @param {Object} request - axios request config ({ method, data })
|
||||
* @param {number} maxBodySize
|
||||
* @returns {string} base64 SHA256 of the (possibly truncated) body, or '' when not applicable
|
||||
*/
|
||||
function makeContentHash(request, maxBodySize) {
|
||||
if (!request.method || request.method.toUpperCase() !== 'POST') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let body = request.data;
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
// Hash the bytes as sent — do NOT parse/re-stringify (that would change the payload).
|
||||
body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
if (body.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (body.length > maxBodySize) {
|
||||
body = body.substring(0, maxBodySize);
|
||||
}
|
||||
return base64Sha256(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an EdgeGrid request.
|
||||
* @param {Object} config - EdgeGrid configuration
|
||||
* @param {string} config.accessToken
|
||||
* @param {string} config.clientToken
|
||||
* @param {string} config.clientSecret
|
||||
* @param {string} [config.baseURL] - override host the request is signed against
|
||||
* @param {string} [config.nonce] - optional nonce override
|
||||
* @param {string} [config.timestamp] - optional timestamp override
|
||||
* @param {string} [config.headersToSign] - comma-separated header names to sign
|
||||
* @param {string|number} [config.maxBodySize=131072]
|
||||
* @param {Object} request - axios request config ({ method, url, headers, data })
|
||||
* @returns {string} Authorization header value
|
||||
*/
|
||||
export function signEdgeGridRequest(config, request) {
|
||||
const { accessToken, clientToken, clientSecret, baseURL, headersToSign } = config;
|
||||
const maxBodySize = config.maxBodySize ? parseInt(config.maxBodySize, 10) : MAX_BODY_SIZE_DEFAULT;
|
||||
|
||||
// Validate required fields
|
||||
if (!isStrPresent(accessToken)) {
|
||||
throw new Error('EdgeGrid: accessToken is required');
|
||||
}
|
||||
if (!isStrPresent(clientToken)) {
|
||||
throw new Error('EdgeGrid: clientToken is required');
|
||||
}
|
||||
if (!isStrPresent(clientSecret)) {
|
||||
throw new Error('EdgeGrid: clientSecret is required');
|
||||
}
|
||||
|
||||
// Generate or use provided nonce and timestamp
|
||||
const nonce = isStrPresent(config.nonce) ? config.nonce : makeEdgeGridNonce();
|
||||
const timestamp = isStrPresent(config.timestamp) ? config.timestamp : makeEdgeGridTimestamp();
|
||||
|
||||
// Determine the URL to sign — use baseURL's host/protocol if provided, otherwise the request URL.
|
||||
let urlToSign = request.url;
|
||||
if (isStrPresent(baseURL)) {
|
||||
const requestUrl = new URL(request.url);
|
||||
// A scheme-less baseURL like "localhost:6000" mis-parses ("localhost:" becomes the protocol
|
||||
// and the host is empty). If there's no "scheme://", borrow the request URL's scheme.
|
||||
const normalizedBaseURL = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseURL.trim())
|
||||
? baseURL.trim()
|
||||
: `${requestUrl.protocol}//${baseURL.trim()}`;
|
||||
const baseParsed = new URL(normalizedBaseURL);
|
||||
urlToSign = `${baseParsed.protocol}//${baseParsed.host}${requestUrl.pathname}${requestUrl.search}`;
|
||||
}
|
||||
const parsedUrl = new URL(urlToSign);
|
||||
|
||||
// Auth header (without the signature) — this exact string is also the LAST field of the
|
||||
// data-to-sign, per the EdgeGrid spec / official library.
|
||||
const authHeader
|
||||
= `EG1-HMAC-SHA256 client_token=${clientToken};access_token=${accessToken};timestamp=${timestamp};nonce=${nonce};`;
|
||||
|
||||
// data-to-sign: tab-joined, in the exact order Akamai expects.
|
||||
const dataToSign = [
|
||||
request.method.toUpperCase(),
|
||||
parsedUrl.protocol.replace(':', ''),
|
||||
parsedUrl.host,
|
||||
parsedUrl.pathname + parsedUrl.search,
|
||||
canonicalizeHeaders(headersToSign, request.headers),
|
||||
makeContentHash(request, maxBodySize),
|
||||
authHeader
|
||||
].join('\t');
|
||||
|
||||
// Signing key is the base64 STRING of HMAC(timestamp, clientSecret); the signature is then
|
||||
// HMAC(dataToSign, signingKey) — both base64-encoded.
|
||||
const signingKey = base64HmacSha256(timestamp, clientSecret);
|
||||
const signature = base64HmacSha256(dataToSign, signingKey);
|
||||
|
||||
return `${authHeader}signature=${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add EdgeGrid interceptor to axios instance
|
||||
* @param {Object} axiosInstance - Axios instance
|
||||
* @param {Object} request - Request object with edgeGridConfig
|
||||
*/
|
||||
export function addEdgeGridInterceptor(axiosInstance, request) {
|
||||
const { edgeGridConfig } = request;
|
||||
|
||||
if (!edgeGridConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add request interceptor to sign requests
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
try {
|
||||
const authHeader = signEdgeGridRequest(edgeGridConfig, config);
|
||||
config.headers['Authorization'] = authHeader;
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('EdgeGrid signing error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
197
packages/bruno-requests/src/auth/edgegrid-helper.spec.js
Normal file
197
packages/bruno-requests/src/auth/edgegrid-helper.spec.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const { signEdgeGridRequest } = require('./edgegrid-helper');
|
||||
|
||||
/**
|
||||
* These vectors are Akamai's OFFICIAL EdgeGrid test fixtures, taken from the reference
|
||||
* implementation (github.com/akamai/AkamaiOPEN-edgegrid-node — test/test.js & test/test_data.json).
|
||||
* Matching them byte-for-byte proves Bruno's signature is interoperable with the real
|
||||
* Akamai gateway. Credentials/nonce/timestamp are the published dummy values.
|
||||
*/
|
||||
const CREDS = {
|
||||
clientToken: 'akab-client-token-xxx-xxxxxxxxxxxxxxxx',
|
||||
accessToken: 'akab-access-token-xxx-xxxxxxxxxxxxxxxx',
|
||||
clientSecret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=',
|
||||
nonce: 'nonce-xx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||
timestamp: '20140321T19:34:21+0000'
|
||||
};
|
||||
const HOST = 'https://akaa-baseurl-xxxxxxxxxxx-xxxxxxxxxxxxx.luna.akamaiapis.net';
|
||||
const HEADERS_TO_SIGN = 'X-Test1,X-Test2,X-Test3';
|
||||
|
||||
const sig = (header) => header.split('signature=')[1];
|
||||
|
||||
describe('signEdgeGridRequest — Akamai official vectors (core)', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'simple GET',
|
||||
config: CREDS,
|
||||
request: { method: 'GET', url: `${HOST}/` },
|
||||
expected: 'tL+y4hxyHxgWVD30X3pWnGKHcPzmrIF+LThiAOhMxYU='
|
||||
},
|
||||
{
|
||||
name: 'GET with query string',
|
||||
config: CREDS,
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t1?p1=1&p2=2` },
|
||||
expected: 'hKDH1UlnQySSHjvIcZpDMbQHihTQ0XyVAKZaApabdeA='
|
||||
},
|
||||
{
|
||||
name: 'POST within body limit (body hashed)',
|
||||
config: CREDS,
|
||||
request: { method: 'POST', url: `${HOST}/testapi/v1/t3`, data: 'datadatadatadatadatadatadatadata' },
|
||||
expected: 'hXm4iCxtpN22m4cbZb4lVLW5rhX8Ca82vCFqXzSTPe4='
|
||||
},
|
||||
{
|
||||
name: 'POST with empty body (no content hash)',
|
||||
config: CREDS,
|
||||
request: { method: 'POST', url: `${HOST}/testapi/v1/t6`, data: '' },
|
||||
expected: '1gEDxeQGD5GovIkJJGcBaKnZ+VaPtrc4qBUHixjsPCQ='
|
||||
}
|
||||
];
|
||||
|
||||
test.each(cases)('$name', ({ config, request, expected }) => {
|
||||
expect(sig(signEdgeGridRequest(config, request))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signEdgeGridRequest — Akamai official vectors (headers_to_sign)', () => {
|
||||
const config = { ...CREDS, headersToSign: HEADERS_TO_SIGN, maxBodySize: 2048 };
|
||||
const cases = [
|
||||
{
|
||||
name: 'single signed header',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': 'test-simple-header' } },
|
||||
expected: '8F9AybcRw+PLxnvT+H0JRkjROrrUgsxJTnRXMzqvcwY='
|
||||
},
|
||||
{
|
||||
name: 'header value with surrounding/quoted spaces (trimmed + collapsed)',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': '" test-header-with-spaces "' } },
|
||||
expected: 'ucq2AbjCNtobHfCTuS38fdkl5UDdWHZhQX46fYR8CqI='
|
||||
},
|
||||
{
|
||||
name: 'header with leading + interior spaces (collapsed)',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test1': ' first-thing second-thing' } },
|
||||
expected: 'WtnneL539UadAAOJwnsXvPqT4Kt6z7HMgBEwAFpt3+c='
|
||||
},
|
||||
{
|
||||
name: 'headers signed in config order regardless of request order',
|
||||
request: { method: 'GET', url: `${HOST}/testapi/v1/t4`, headers: { 'X-Test2': 't2', 'X-Test1': 't1', 'X-Test3': 't3' } },
|
||||
expected: 'Wus73Nx8jOYM+kkBFF2q8D1EATRIMr0WLWwpLBgkBqY='
|
||||
},
|
||||
{
|
||||
name: 'headers not in the sign list are excluded',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: `${HOST}/testapi/v1/t5`,
|
||||
headers: { 'X-Test2': 't2', 'X-Test1': 't1', 'X-Test3': 't3', 'X-Extra': 'this won\'t be included' }
|
||||
},
|
||||
expected: 'Knd/jc0A5Ghhizjayr0AUUvl2MZjBpS3FDSzvtq4Ixc='
|
||||
}
|
||||
];
|
||||
|
||||
test.each(cases)('$name', ({ request, expected }) => {
|
||||
expect(sig(signEdgeGridRequest(config, request))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signEdgeGridRequest — body hashing rules', () => {
|
||||
test('non-POST methods are NOT body-hashed (PUT with data)', () => {
|
||||
const header = signEdgeGridRequest(CREDS, {
|
||||
method: 'PUT',
|
||||
url: `${HOST}/testapi/v1/t6`,
|
||||
data: 'PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP'
|
||||
});
|
||||
// Matches the Akamai fixture for a PUT (no content hash since only POST is hashed).
|
||||
expect(sig(header)).toBe('GNBWEYSEWOLtu+7dD52da2C39aX/Jchpon3K/AmBqBU=');
|
||||
});
|
||||
|
||||
test('POST body is hashed as-sent (no JSON re-compaction)', () => {
|
||||
const pretty = '{\n "name": "bruno"\n}';
|
||||
const compact = '{"name":"bruno"}';
|
||||
const req = (data) => ({ method: 'POST', url: `${HOST}/x`, data });
|
||||
// Different bytes on the wire ⇒ different signature (proves we hash what we send).
|
||||
expect(sig(signEdgeGridRequest(CREDS, req(pretty)))).not.toBe(sig(signEdgeGridRequest(CREDS, req(compact))));
|
||||
});
|
||||
|
||||
test('POST body is truncated to maxBodySize before hashing', () => {
|
||||
const config = { ...CREDS, maxBodySize: 16 };
|
||||
const a = { method: 'POST', url: `${HOST}/x`, data: 'd'.repeat(16) + 'AAAAAAAA' };
|
||||
const b = { method: 'POST', url: `${HOST}/x`, data: 'd'.repeat(16) + 'BBBBBBBB' };
|
||||
// Bytes beyond maxBodySize are ignored ⇒ identical signature.
|
||||
expect(sig(signEdgeGridRequest(config, a))).toBe(sig(signEdgeGridRequest(config, b)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('signEdgeGridRequest — config behaviour', () => {
|
||||
test('throws when accessToken is missing', () => {
|
||||
expect(() => signEdgeGridRequest({ ...CREDS, accessToken: '' }, { method: 'GET', url: `${HOST}/` })).toThrow(
|
||||
/accessToken is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when clientToken is missing', () => {
|
||||
expect(() => signEdgeGridRequest({ ...CREDS, clientToken: '' }, { method: 'GET', url: `${HOST}/` })).toThrow(
|
||||
/clientToken is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when clientSecret is missing', () => {
|
||||
expect(() => signEdgeGridRequest({ ...CREDS, clientSecret: '' }, { method: 'GET', url: `${HOST}/` })).toThrow(
|
||||
/clientSecret is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('auto-generates nonce and timestamp when not provided', () => {
|
||||
const header = signEdgeGridRequest(
|
||||
{ clientToken: CREDS.clientToken, accessToken: CREDS.accessToken, clientSecret: CREDS.clientSecret },
|
||||
{ method: 'GET', url: `${HOST}/` }
|
||||
);
|
||||
// a UUID v4 nonce and an EdgeGrid timestamp (YYYYMMDDTHH:MM:SS+0000) are present
|
||||
expect(header).toMatch(/nonce=[0-9a-f-]{36};/);
|
||||
expect(header).toMatch(/timestamp=\d{8}T\d{2}:\d{2}:\d{2}\+0000;/);
|
||||
expect(header).toMatch(/signature=.+$/);
|
||||
});
|
||||
|
||||
test('baseURL overrides the host the request is signed against', () => {
|
||||
// Signing the request URL directly vs. signing via a different baseURL host ⇒ different signature
|
||||
const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: `${HOST}/path` });
|
||||
const viaBase = signEdgeGridRequest(
|
||||
{ ...CREDS, baseURL: 'https://other-host.luna.akamaiapis.net' },
|
||||
{ method: 'GET', url: `${HOST}/path` }
|
||||
);
|
||||
expect(sig(direct)).not.toBe(sig(viaBase));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Equivalents of httpie-edgegrid's test_localhost / test_http_to_https_conversion: the EFFECTIVE
|
||||
* signed URL is the baseURL host + the request's path/query, so the literal request host (e.g.
|
||||
* "localhost") is replaced. We prove this by asserting the signature equals signing the fully resolved absolute URL directly.
|
||||
* Reference : https://github.com/akamai/httpie-edgegrid/blob/master/test/test_httpie_edgegrid.py
|
||||
*/
|
||||
describe('signEdgeGridRequest — baseURL host/scheme override (httpie test_localhost equivalents)', () => {
|
||||
const REAL_HOST = 'https://realhost-xxxxxxxx.luna.akamaiapis.net';
|
||||
|
||||
test('localhost is replaced by the baseURL host (test_localhost)', () => {
|
||||
const viaBase = signEdgeGridRequest(
|
||||
{ ...CREDS, baseURL: REAL_HOST },
|
||||
{ method: 'GET', url: 'https://localhost/identity-management/v3/user-profile?q=1' }
|
||||
);
|
||||
const direct = signEdgeGridRequest(CREDS, {
|
||||
method: 'GET',
|
||||
url: `${REAL_HOST}/identity-management/v3/user-profile?q=1`
|
||||
});
|
||||
expect(sig(viaBase)).toBe(sig(direct));
|
||||
});
|
||||
|
||||
test('an http request is signed against the https baseURL (test_http_to_https_conversion)', () => {
|
||||
const viaBase = signEdgeGridRequest({ ...CREDS, baseURL: REAL_HOST }, { method: 'GET', url: 'http://localhost/path' });
|
||||
const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: `${REAL_HOST}/path` });
|
||||
expect(sig(viaBase)).toBe(sig(direct));
|
||||
});
|
||||
|
||||
test('scheme-less baseURL ("host:port") borrows the request scheme', () => {
|
||||
const viaBase = signEdgeGridRequest(
|
||||
{ ...CREDS, baseURL: 'localhost:6000' },
|
||||
{ method: 'GET', url: 'http://localhost:9999/path' }
|
||||
);
|
||||
const direct = signEdgeGridRequest(CREDS, { method: 'GET', url: 'http://localhost:6000/path' });
|
||||
expect(sig(viaBase)).toBe(sig(direct));
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export { addDigestInterceptor } from './digestauth-helper';
|
||||
export { getOAuth2Token } from './oauth2-helper';
|
||||
export { createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './oauth1-request-authorization';
|
||||
export { addEdgeGridInterceptor, signEdgeGridRequest } from './edgegrid-helper';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest } from './auth';
|
||||
export { addDigestInterceptor, getOAuth2Token, createOAuth1Authorizer, computeBodyHash, applyOAuth1ToRequest, addEdgeGridInterceptor } from './auth';
|
||||
export { GrpcClient, generateGrpcSampleMessage } from './grpc';
|
||||
export { WsClient } from './ws/ws-client';
|
||||
export { default as cookies } from './cookies';
|
||||
|
||||
Reference in New Issue
Block a user