feat(auth): akamai auth integration (#8199)

This commit is contained in:
shubh-bruno
2026-07-03 13:31:28 +05:30
committed by GitHub
parent 876a4c4cd3
commit efd61bf682
58 changed files with 2782 additions and 30 deletions

View 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);
});
}

View 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));
});
});

View File

@@ -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';

View File

@@ -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';