Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .pull_request/pr-105-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# feat: implement mobile money callback signature verification (#105)

## Summary

Adds provider-specific callback signature verification for MTN MoMo and Airtel Money inbound webhook callbacks. Unverified callbacks are rejected with HTTP 403 and logged as security anomaly events.

## Changes

### MTN MoMo (`src/middleware/mtnCallbackSignature.ts`)
- Fixed response code: unverified callbacks now return **403 Forbidden** (was 401) per acceptance criteria
- HMAC-SHA256 verification using `MTN_CALLBACK_SECRET` (subscription key) on the `X-Callback-Signature` header
- Supports both `sha256=` prefixed hex and plain base64 signature formats
- Timing-safe comparison via `timingSafeEqual`
- All rejection paths log a `security.anomaly` event with reason code

### Airtel Money — new

| File | Description |
|---|---|
| `src/middleware/airtelCallbackSignature.ts` | New middleware — validates `Authorization: Bearer {token}` against `AIRTEL_CALLBACK_SECRET` using `timingSafeEqual`. Missing = 403. Invalid = 403. Unconfigured = 500. All failures logged as security anomalies. |
| `src/routes/airtelCallbacks.ts` | New router — `ingestRateLimiter` → `verifyAirtelCallbackSignature` → `POST /callback` → `{ status: "accepted" }` |
| `src/config/appConfig.ts` | Added `providers.airtel.callbackSecret` config entry (env: `AIRTEL_CALLBACK_SECRET`) |
| `src/index.ts` | Mounted Airtel callback router at `app.use("/api/airtel", airtelCallbacksRouter)` |

### Tests

| File | Tests |
|---|---|
| `src/middleware/__tests__/airtelCallbackSignature.test.ts` | 5 unit tests: unconfigured secret (500), missing header (403), non-Bearer scheme (403), valid token (200), wrong token (403) |
| `src/routes/__tests__/airtelCallbacks.test.ts` | 4 integration tests via supertest: valid bearer (200), missing auth (403), wrong token (403), Basic scheme (403) |
| `src/middleware/__tests__/mtnCallbackSignature.test.ts` | Updated — expect 403 instead of 401 |
| `src/routes/__tests__/mtnCallbacks.test.ts` | Updated — expect 403 instead of 401 |

## Environment Variables Required

```env
MTN_CALLBACK_SECRET=<mtn-subscription-key-or-hmac-secret>
AIRTEL_CALLBACK_SECRET=<airtel-shared-secret-token>
```

## Acceptance Criteria

- ✅ MTN MoMo callbacks validate `X-Callback-Signature` header using HMAC-SHA256 with subscription key
- ✅ Airtel callbacks validate `Authorization: Bearer` token against expected shared secret
- ✅ Unverified callbacks return 403 and are logged as security events
- ✅ Verification logic is unit tested with known valid and invalid signature vectors

closes #105
6 changes: 6 additions & 0 deletions src/config/appConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ export const configSchema = convict({
default: 1000000,
env: "AIRTEL_MAX_AMOUNT",
},
callbackSecret: {
doc: "Airtel callback shared secret for verifying incoming callbacks",
format: String,
default: "",
env: "AIRTEL_CALLBACK_SECRET",
},
},
orange: {
minAmount: {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { privacyRoutes } from "./routes/privacy";
import { developerDashboardRoutes } from "./routes/developerDashboard";
import { travelRuleRoutes } from "./routes/travelRule";
import mtnCallbacksRouter from "./routes/mtnCallbacks";
import airtelCallbacksRouter from "./routes/airtelCallbacks";
import sep31Router from "./stellar/sep31";
import sep24Router from "./stellar/sep24";
import sep38Router from "./stellar/sep38";
Expand Down Expand Up @@ -372,6 +373,7 @@ app.use("/api/disputes", disputeRoutes);
app.use("/api/stats", statsRoutes);
app.use("/api/contacts", contactsRoutes);
app.use("/api/mtn", mtnCallbacksRouter);
app.use("/api/airtel", airtelCallbacksRouter);
app.use("/api/reports", reportsRoutes);
app.use("/api/fees", feesRoutes);
app.use("/api/users", userRoutes);
Expand Down
129 changes: 129 additions & 0 deletions src/middleware/__tests__/airtelCallbackSignature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextFunction, Request, Response } from "express";

// Mock appConfig before importing the middleware
const mockGetConfigValue = jest.fn();
jest.mock("../../config/appConfig", () => ({
getConfigValue: mockGetConfigValue,
}));

// Mock logger
const mockLogSecurityAnomaly = jest.fn();
jest.mock("../../services/logger", () => ({
getCurrentRequestIp: jest.fn(() => "1.2.3.4"),
logSecurityAnomaly: mockLogSecurityAnomaly,
}));

import { verifyAirtelCallbackSignature } from "../airtelCallbackSignature";

const SECRET = "test-airtel-secret-token";

function makeReq(overrides: Partial<Request> = {}): Request {
return {
headers: {},
body: {},
method: "POST",
originalUrl: "/api/airtel/callback",
url: "/api/airtel/callback",
...overrides,
} as unknown as Request;
}

function makeRes(): Response {
const res: Partial<Response> = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res as Response;
}

beforeEach(() => {
jest.clearAllMocks();
mockGetConfigValue.mockImplementation((key: string) => {
if (key === "providers.airtel.callbackSecret") return SECRET;
return undefined;
});
});

describe("verifyAirtelCallbackSignature", () => {
describe("secret not configured", () => {
it("returns 500 and logs anomaly when secret is missing", async () => {
mockGetConfigValue.mockImplementation((key: string) => {
if (key === "providers.airtel.callbackSecret") return "";
return undefined;
});

const req = makeReq();
const res = makeRes();
const next: NextFunction = jest.fn();

await verifyAirtelCallbackSignature(req, res, next);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: "Airtel callback verification not configured",
});
expect(next).not.toHaveBeenCalled();
expect(mockLogSecurityAnomaly).toHaveBeenCalledWith(
expect.objectContaining({ reason: "airtel_callback_secret_not_configured" }),
);
});
});

describe("missing Authorization header", () => {
it("rejects with 403 when Authorization header is absent", async () => {
const req = makeReq({ headers: {} });
const next: NextFunction = jest.fn();

await expect(
verifyAirtelCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(next).not.toHaveBeenCalled();
expect(mockLogSecurityAnomaly).toHaveBeenCalledWith(
expect.objectContaining({ reason: "airtel_callback_token_missing" }),
);
});

it("rejects with 403 when Authorization header is not Bearer", async () => {
const req = makeReq({ headers: { authorization: "Basic abc123" } });
const next: NextFunction = jest.fn();

await expect(
verifyAirtelCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(next).not.toHaveBeenCalled();
});
});

describe("valid token", () => {
it("calls next() for a valid bearer token", async () => {
const req = makeReq({
headers: { authorization: `Bearer ${SECRET}` },
});
const next: NextFunction = jest.fn();

await verifyAirtelCallbackSignature(req, makeRes(), next);

expect(next).toHaveBeenCalled();
expect(mockLogSecurityAnomaly).not.toHaveBeenCalled();
});
});

describe("invalid token", () => {
it("rejects with 403 for a wrong bearer token", async () => {
const req = makeReq({
headers: { authorization: "Bearer wrong-token-value" },
});
const next: NextFunction = jest.fn();

await expect(
verifyAirtelCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(next).not.toHaveBeenCalled();
expect(mockLogSecurityAnomaly).toHaveBeenCalledWith(
expect.objectContaining({ reason: "airtel_callback_token_invalid", headerPresent: true }),
);
});
});
});
16 changes: 8 additions & 8 deletions src/middleware/__tests__/mtnCallbackSignature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@ describe("verifyMtnCallbackSignature", () => {
});

describe("signature header missing", () => {
it("throws 401 and logs anomaly when no signature header is present", async () => {
it("throws 403 and logs anomaly when no signature header is present", async () => {
const req = makeReq({ headers: {} });
const next: NextFunction = jest.fn();

await expect(
verifyMtnCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(next).not.toHaveBeenCalled();
expect(mockLogSecurityAnomaly).toHaveBeenCalledWith(
Expand Down Expand Up @@ -165,7 +165,7 @@ describe("verifyMtnCallbackSignature", () => {
});

describe("invalid signatures", () => {
it("throws 401 for a tampered payload", async () => {
it("throws 403 for a tampered payload", async () => {
const rawBody = Buffer.from(PAYLOAD);
const sig = hmacBase64("different-payload", SECRET);
const req = makeReq({
Expand All @@ -176,15 +176,15 @@ describe("verifyMtnCallbackSignature", () => {

await expect(
verifyMtnCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(next).not.toHaveBeenCalled();
expect(mockLogSecurityAnomaly).toHaveBeenCalledWith(
expect.objectContaining({ reason: "mtn_callback_signature_invalid" }),
);
});

it("throws 401 for a wrong secret", async () => {
it("throws 403 for a wrong secret", async () => {
const rawBody = Buffer.from(PAYLOAD);
const sig = hmacBase64(PAYLOAD, "wrong-secret");
const req = makeReq({
Expand All @@ -195,12 +195,12 @@ describe("verifyMtnCallbackSignature", () => {

await expect(
verifyMtnCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(next).not.toHaveBeenCalled();
});

it("throws 401 for a signature with mismatched length", async () => {
it("throws 403 for a signature with mismatched length", async () => {
const rawBody = Buffer.from(PAYLOAD);
const req = makeReq({
headers: { "x-callback-signature": "short" },
Expand All @@ -210,7 +210,7 @@ describe("verifyMtnCallbackSignature", () => {

await expect(
verifyMtnCallbackSignature(req, makeRes(), next),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
).rejects.toMatchObject({ code: "FORBIDDEN" });
});

it("logs anomaly with headerPresent=true for invalid signature", async () => {
Expand Down
88 changes: 88 additions & 0 deletions src/middleware/airtelCallbackSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { timingSafeEqual } from "crypto";
import { NextFunction, Request, Response } from "express";
import { getConfigValue } from "../config/appConfig";
import { getCurrentRequestIp, logSecurityAnomaly } from "../services/logger";
import { ERROR_CODES } from "../constants/errorCodes";
import { createError } from "./errorHandler";

function getAirtelCallbackSecret(): string {
const secret = getConfigValue("providers.airtel.callbackSecret");
return String(secret ?? "").trim();
}

function extractBearerToken(req: Request): string | undefined {
const authHeader = req.headers["authorization"] as string | undefined;
if (!authHeader) return undefined;
const parts = authHeader.split(" ");
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return undefined;
}

function buildAirtelFailureEvent(
req: Request,
reason: string,
headerPresent: boolean,
): void {
logSecurityAnomaly({
event: "security.anomaly",
timestamp: new Date().toISOString(),
path: req.originalUrl || req.url,
method: req.method,
ip: getCurrentRequestIp(req),
reason,
provider: "airtel",
headerPresent,
});
}

export async function verifyAirtelCallbackSignature(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const callbackSecret = getAirtelCallbackSecret();

if (!callbackSecret) {
buildAirtelFailureEvent(req, "airtel_callback_secret_not_configured", false);
res.status(500).json({ error: "Airtel callback verification not configured" });
return;
}

const token = extractBearerToken(req);

if (!token) {
buildAirtelFailureEvent(req, "airtel_callback_token_missing", false);
throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", {
error: "Forbidden",
});
}

try {
const expectedBuf = Buffer.from(callbackSecret);
const incomingBuf = Buffer.from(token);

const isValid =
expectedBuf.length === incomingBuf.length &&
timingSafeEqual(expectedBuf, incomingBuf);

if (!isValid) {
buildAirtelFailureEvent(req, "airtel_callback_token_invalid", true);
throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", {
error: "Forbidden",
});
}

next();
} catch (error: any) {
// Re-throw structured errors (from createError) directly
if (error?.code === ERROR_CODES.FORBIDDEN) {
throw error;
}
buildAirtelFailureEvent(req, "airtel_callback_token_error", true);
throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", {
error: "Forbidden",
});
}
}
12 changes: 6 additions & 6 deletions src/middleware/mtnCallbackSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export async function verifyMtnCallbackSignature(

if (!signature) {
buildFailureEvent(req, "mtn_callback_signature_missing", false);
throw createError(ERROR_CODES.UNAUTHORIZED, "Unauthorized callback", {
error: "Unauthorized callback",
throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", {
error: "Forbidden",
});
}

Expand All @@ -105,16 +105,16 @@ export async function verifyMtnCallbackSignature(
try {
if (!verifySignature(payload, signature, callbackSecret)) {
buildFailureEvent(req, "mtn_callback_signature_invalid", true);
throw createError(ERROR_CODES.UNAUTHORIZED, "Unauthorized callback", {
error: "Unauthorized callback",
throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", {
error: "Forbidden",
});
}

next();
} catch (error) {
buildFailureEvent(req, "mtn_callback_signature_error", true);
throw createError(ERROR_CODES.UNAUTHORIZED, "Unauthorized callback", {
error: "Unauthorized callback",
throw createError(ERROR_CODES.FORBIDDEN, "Forbidden", {
error: "Forbidden",
});
}
}
Loading
Loading