Phase9
This commit is contained in:
18
packages/integration-auth/package.json
Normal file
18
packages/integration-auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
packages/integration-auth/src/hmac.test.ts
Normal file
25
packages/integration-auth/src/hmac.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
packages/integration-auth/src/hmac.ts
Normal file
81
packages/integration-auth/src/hmac.ts
Normal 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('&');
|
||||
}
|
||||
9
packages/integration-auth/src/index.ts
Normal file
9
packages/integration-auth/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
INTEGRATION_HEADERS,
|
||||
buildSignatureBase,
|
||||
canonicalQuery,
|
||||
hashBody,
|
||||
signRequest,
|
||||
verifyRequestSignature,
|
||||
type SignedRequestParts,
|
||||
} from './hmac';
|
||||
9
packages/integration-auth/tsconfig.json
Normal file
9
packages/integration-auth/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user