Phase9
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
smueller
2026-06-26 13:46:25 +02:00
parent b335f6a497
commit ed8334328e
49 changed files with 2219 additions and 16 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/integration-auth",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { signRequest, verifyRequestSignature } from './hmac';
describe('integration HMAC', () => {
it('signs and verifies canonical requests', () => {
const parts = {
method: 'POST',
path: '/api/v1/integrations/whmcs/services',
query: '',
body: '{"externalServiceId":"42"}',
timestamp: '1710000000000',
nonce: 'nonce-1',
idempotencyKey: 'idem-1',
};
const signature = signRequest('test-secret', parts);
expect(
verifyRequestSignature('test-secret', parts, signature, {
now: 1_710_000_000_000,
}),
).toBe(true);
expect(verifyRequestSignature('wrong', parts, signature)).toBe(false);
});
});

View File

@@ -0,0 +1,81 @@
import { createHmac, timingSafeEqual, createHash } from 'node:crypto';
export const INTEGRATION_HEADERS = {
integrationId: 'x-hgc-integration-id',
timestamp: 'x-hgc-timestamp',
nonce: 'x-hgc-nonce',
idempotencyKey: 'x-hgc-idempotency-key',
signature: 'x-hgc-signature',
correlationId: 'x-correlation-id',
} as const;
export interface SignedRequestParts {
method: string;
path: string;
query: string;
body: string;
timestamp: string;
nonce: string;
idempotencyKey: string;
}
export function hashBody(body: string): string {
return createHash('sha256').update(body).digest('hex');
}
export function buildSignatureBase(parts: SignedRequestParts): string {
return [
parts.method.toUpperCase(),
parts.path,
parts.query,
hashBody(parts.body),
parts.timestamp,
parts.nonce,
parts.idempotencyKey,
].join('\n');
}
export function signRequest(secret: string, parts: SignedRequestParts): string {
const base = buildSignatureBase(parts);
return createHmac('sha256', secret).update(base).digest('hex');
}
export function verifyRequestSignature(
secret: string,
parts: SignedRequestParts,
providedSignature: string,
options?: { maxSkewMs?: number; now?: number },
): boolean {
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
const now = options?.now ?? Date.now();
const timestampMs = Number.parseInt(parts.timestamp, 10);
if (!Number.isFinite(timestampMs)) {
return false;
}
if (Math.abs(now - timestampMs) > maxSkewMs) {
return false;
}
const expected = signRequest(secret, parts);
const expectedBuffer = Buffer.from(expected, 'utf8');
const providedBuffer = Buffer.from(providedSignature, 'utf8');
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, providedBuffer);
}
export function canonicalQuery(query: Record<string, string | string[] | undefined>): string {
return Object.entries(query)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
const normalized = Array.isArray(value) ? value[0] : value;
return `${encodeURIComponent(key)}=${encodeURIComponent(normalized ?? '')}`;
})
.sort()
.join('&');
}

View File

@@ -0,0 +1,9 @@
export {
INTEGRATION_HEADERS,
buildSignatureBase,
canonicalQuery,
hashBody,
signRequest,
verifyRequestSignature,
type SignedRequestParts,
} from './hmac';

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}