feat(hooks): support custom verifyAuth in transform modules

Add ability for hook mapping transform modules to export a verifyAuth
function for custom webhook authentication (e.g., GitHub HMAC signatures).

When a mapping's transform exports verifyAuth, it replaces standard
token auth for that mapping. Returns true to allow, false to reject.

Flow in server-http.ts:
1. Read raw body + parse JSON
2. findMapping() to match on path/source
3. authenticateHook() with matched transform
4. Route: wake / agent / applyMapping()

Changes:
- hooks.ts: Split readJsonBody into readRawBody + parseJsonBody;
  add authenticateHook() for custom or token auth
- hooks-mapping.ts: Add verifyAuth types, loadVerifyAuth(),
  findMapping(), applyMapping(); CachedTransform for caching
- server-http.ts: Linear flow using the above
- Tests for authenticateHook and loadVerifyAuth
- Document verifyAuth with GitHub HMAC example
This commit is contained in:
Bradley Priest 2026-01-30 15:52:15 +13:00 committed by Bradley Priest
parent 0f7b984469
commit 45b41672c6
6 changed files with 505 additions and 72 deletions

View File

@ -255,6 +255,91 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
-d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}'
```
## Custom Authentication (verifyAuth)
External webhooks (GitHub, Stripe, Linear, etc.) often use their own authentication
schemes (e.g., HMAC signatures). Instead of using the standard bearer token, you can
export a `verifyAuth` function from your transform module to handle custom auth.
When a mapping's transform module exports `verifyAuth`, it runs **before** the standard
token check. If it returns `true`, the request is authorized; if `false`, a 401 is returned.
### Example: GitHub Webhook Signature Verification
The `verifyAuth` function receives a context with the raw body for signature verification:
```typescript
type HookVerifyAuthContext = {
headers: Record<string, string>; // Lowercase header names
url: URL; // Parsed request URL
path: string; // Subpath after /hooks/
rawBody: Buffer; // Raw request body for signature verification
};
```
```javascript
// hooks/github-transform.js
import { createHmac, timingSafeEqual } from "crypto";
// Runs BEFORE token auth - return true to allow, false to reject
export function verifyAuth(ctx) {
const signature = ctx.headers["x-hub-signature-256"];
if (!signature) return false;
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const expected = "sha256=" + createHmac("sha256", secret)
.update(ctx.rawBody)
.digest("hex");
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
// Transform the payload (runs after auth passes)
export default function transform(ctx) {
const event = ctx.headers["x-github-event"];
// Format based on event type
if (event === "push") {
return { message: `Push to ${ctx.payload.repository?.full_name}: ${ctx.payload.head_commit?.message}` };
}
if (event === "pull_request") {
return { message: `PR ${ctx.payload.action}: ${ctx.payload.pull_request?.title}` };
}
return { message: `GitHub ${event}: ${JSON.stringify(ctx.payload).slice(0, 200)}` };
}
```
Config:
```yaml
hooks:
enabled: true
token: "regular-token" # Still needed for non-custom-auth hooks
mappings:
- id: github
match:
path: github
action: agent
name: GitHub
transform:
module: github-transform.js
```
### Async verifyAuth
`verifyAuth` can be async if needed:
```javascript
export async function verifyAuth(ctx) {
// async validation (e.g., checking against external service)
return await validateSignature(ctx.headers, ctx.rawBody);
}
```
## Security
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.

View File

@ -3,7 +3,24 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js";
import {
applyMapping,
findMapping,
type HookMappingContext,
type HookMappingResult,
loadVerifyAuth,
resolveHookMappings,
} from "./hooks-mapping.js";
/** Test helper: find first matching mapping and apply it. */
async function applyFirstMatch(
mappings: ReturnType<typeof resolveHookMappings>,
ctx: HookMappingContext,
): Promise<HookMappingResult | null> {
const matched = findMapping(mappings, ctx);
if (!matched) return null;
return applyMapping(matched, ctx);
}
const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail");
@ -25,7 +42,7 @@ describe("hooks mapping", () => {
},
],
});
const result = await applyHookMappings(mappings, {
const result = await applyFirstMatch(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
@ -50,7 +67,7 @@ describe("hooks mapping", () => {
},
],
});
const result = await applyHookMappings(mappings, {
const result = await applyFirstMatch(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
@ -82,7 +99,7 @@ describe("hooks mapping", () => {
],
});
const result = await applyHookMappings(mappings, {
const result = await applyFirstMatch(mappings, {
payload: { name: "Ada" },
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/custom"),
@ -114,7 +131,7 @@ describe("hooks mapping", () => {
],
});
const result = await applyHookMappings(mappings, {
const result = await applyFirstMatch(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/skip"),
@ -140,7 +157,7 @@ describe("hooks mapping", () => {
},
],
});
const result = await applyHookMappings(mappings, {
const result = await applyFirstMatch(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
@ -157,7 +174,7 @@ describe("hooks mapping", () => {
const mappings = resolveHookMappings({
mappings: [{ match: { path: "noop" }, action: "agent" }],
});
const result = await applyHookMappings(mappings, {
const result = await applyFirstMatch(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/noop"),
@ -166,3 +183,73 @@ describe("hooks mapping", () => {
expect(result?.ok).toBe(false);
});
});
describe("loadVerifyAuth", () => {
it("returns undefined when transform has no verifyAuth export", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-hooks-noauth-"));
const modPath = path.join(dir, "transform.mjs");
fs.writeFileSync(modPath, "export default () => ({ message: 'test' });");
const mappings = resolveHookMappings({
transformsDir: dir,
mappings: [
{
match: { path: "github" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
});
const result = await loadVerifyAuth(mappings[0]!.transform!);
expect(result).toBeUndefined();
});
it("returns verifyAuth function when exported", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-hooks-auth-"));
const modPath = path.join(dir, "transform.mjs");
fs.writeFileSync(
modPath,
`
export function verifyAuth(ctx) {
return ctx.headers["x-secret"] === "valid";
}
export default () => ({ message: 'test' });
`,
);
const mappings = resolveHookMappings({
transformsDir: dir,
mappings: [
{
match: { path: "github" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
});
const result = await loadVerifyAuth(mappings[0]!.transform!);
expect(result).toBeDefined();
expect(typeof result).toBe("function");
// Test the verifyAuth function works
if (result) {
const valid = await result({
headers: { "x-secret": "valid" },
url: new URL("http://localhost/hooks/github"),
path: "github",
rawBody: Buffer.from("{}"),
});
expect(valid).toBe(true);
const invalid = await result({
headers: { "x-secret": "wrong" },
url: new URL("http://localhost/hooks/github"),
path: "github",
rawBody: Buffer.from("{}"),
});
expect(invalid).toBe(false);
}
});
});

View File

@ -77,7 +77,30 @@ const hookPresetMappings: Record<string, HookMappingConfig[]> = {
],
};
const transformCache = new Map<string, HookTransformFn>();
/**
* Context for custom auth verification in hook transforms.
* Similar to HookMappingContext but includes the raw body buffer
* for signature verification (e.g., GitHub HMAC signatures).
*/
export type HookVerifyAuthContext = {
headers: Record<string, string>;
url: URL;
path: string;
rawBody: Buffer;
};
/**
* Custom auth verification function exported by transform modules.
* Return true to allow the request, false to reject with 401.
*/
export type HookVerifyAuthFn = (ctx: HookVerifyAuthContext) => boolean | Promise<boolean>;
type CachedTransform = {
transform: HookTransformFn;
verifyAuth?: HookVerifyAuthFn;
};
const transformCache = new Map<string, CachedTransform>();
type HookTransformResult = Partial<{
kind: HookAction["kind"];
@ -129,32 +152,33 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[]
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
}
export async function applyHookMappings(
export function findMapping(
mappings: HookMappingResolved[],
ctx: HookMappingContext,
): Promise<HookMappingResult | null> {
if (mappings.length === 0) return null;
for (const mapping of mappings) {
if (!mappingMatches(mapping, ctx)) continue;
): HookMappingResolved | undefined {
return mappings.find((m) => mappingMatches(m, ctx));
}
const base = buildActionFromMapping(mapping, ctx);
if (!base.ok) return base;
export async function applyMapping(
mapping: HookMappingResolved,
ctx: HookMappingContext,
): Promise<HookMappingResult> {
const base = buildActionFromMapping(mapping, ctx);
if (!base.ok) return base;
let override: HookTransformResult = null;
if (mapping.transform) {
const transform = await loadTransform(mapping.transform);
override = await transform(ctx);
if (override === null) {
return { ok: true, action: null, skipped: true };
}
let override: HookTransformResult = null;
if (mapping.transform) {
const transform = await loadTransform(mapping.transform);
override = await transform(ctx);
if (override === null) {
return { ok: true, action: null, skipped: true };
}
if (!base.action) return { ok: true, action: null, skipped: true };
const merged = mergeAction(base.action, override, mapping.action);
if (!merged.ok) return merged;
return merged;
}
return null;
if (!base.action) return { ok: true, action: null, skipped: true };
const merged = mergeAction(base.action, override, mapping.action);
if (!merged.ok) return merged;
return merged;
}
function normalizeHookMapping(
@ -293,14 +317,42 @@ function validateAction(action: HookAction): HookMappingResult {
return { ok: true, action };
}
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
async function loadTransformModule(
transform: HookMappingTransformResolved,
): Promise<CachedTransform> {
const cached = transformCache.get(transform.modulePath);
if (cached) return cached;
const url = pathToFileURL(transform.modulePath).href;
const mod = (await import(url)) as Record<string, unknown>;
const fn = resolveTransformFn(mod, transform.exportName);
transformCache.set(transform.modulePath, fn);
return fn;
const transformFn = resolveTransformFn(mod, transform.exportName);
const verifyAuth = resolveVerifyFn(mod);
const result: CachedTransform = { transform: transformFn, verifyAuth };
transformCache.set(transform.modulePath, result);
return result;
}
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
const cached = await loadTransformModule(transform);
return cached.transform;
}
/**
* Load a transform module and return its verifyAuth export, if any.
*/
export async function loadVerifyAuth(
transform: HookMappingTransformResolved,
): Promise<HookVerifyAuthFn | undefined> {
const mod = await loadTransformModule(transform);
return mod.verifyAuth;
}
function resolveVerifyFn(mod: Record<string, unknown>): HookVerifyAuthFn | undefined {
const candidate = mod.verifyAuth;
if (candidate == null) return undefined;
if (typeof candidate !== "function") {
throw new Error("hook verifyAuth export must be a function");
}
return candidate as HookVerifyAuthFn;
}
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {

View File

@ -1,4 +1,5 @@
import type { IncomingMessage } from "node:http";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
@ -8,7 +9,10 @@ import {
extractHookToken,
normalizeAgentPayload,
normalizeWakePayload,
parseJsonBody,
readRawBody,
resolveHooksConfig,
authenticateHook,
} from "./hooks.js";
describe("gateway hooks helpers", () => {
@ -150,3 +154,92 @@ const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => (
resolveAccount: () => ({}),
},
});
describe("readRawBody and parseJsonBody", () => {
test("readRawBody reads stream into buffer", async () => {
const req = Readable.from([
Buffer.from('{"foo":'),
Buffer.from('"bar"}'),
]) as unknown as IncomingMessage;
const result = await readRawBody(req, 1024);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.toString()).toBe('{"foo":"bar"}');
}
});
test("parseJsonBody parses valid JSON", () => {
const result = parseJsonBody(Buffer.from('{"hello":"world"}'));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toEqual({ hello: "world" });
}
});
test("parseJsonBody returns empty object for empty buffer", () => {
const result = parseJsonBody(Buffer.from(""));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toEqual({});
}
});
test("parseJsonBody rejects invalid JSON", () => {
const result = parseJsonBody(Buffer.from("not json"));
expect(result.ok).toBe(false);
});
});
describe("authenticateHook", () => {
function createMockRequest(headers: Record<string, string> = {}): IncomingMessage {
return { headers } as unknown as IncomingMessage;
}
test("valid token succeeds", async () => {
const result = await authenticateHook({
req: createMockRequest({ authorization: "Bearer secret123" }),
url: new URL("http://localhost/hooks/test"),
subPath: "test",
headers: { authorization: "Bearer secret123" },
rawBody: Buffer.from("{}"),
transform: undefined,
expectedToken: "secret123",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.tokenFromQuery).toBe(false);
}
});
test("invalid token fails", async () => {
const result = await authenticateHook({
req: createMockRequest({ authorization: "Bearer wrong" }),
url: new URL("http://localhost/hooks/test"),
subPath: "test",
headers: { authorization: "Bearer wrong" },
rawBody: Buffer.from("{}"),
transform: undefined,
expectedToken: "secret123",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(401);
}
});
test("query token sets tokenFromQuery", async () => {
const result = await authenticateHook({
req: createMockRequest({}),
url: new URL("http://localhost/hooks/test?token=secret123"),
subPath: "test",
headers: {},
rawBody: Buffer.from("{}"),
transform: undefined,
expectedToken: "secret123",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.tokenFromQuery).toBe(true);
}
});
});

View File

@ -4,7 +4,13 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { MoltbotConfig } from "../config/config.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
import {
type HookMappingResolved,
type HookMappingTransformResolved,
type HookVerifyAuthFn,
loadVerifyAuth,
resolveHookMappings,
} from "./hooks-mapping.js";
const DEFAULT_HOOKS_PATH = "/hooks";
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
@ -61,10 +67,10 @@ export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResul
return { token: undefined, fromQuery: false };
}
export async function readJsonBody(
export async function readRawBody(
req: IncomingMessage,
maxBytes: number,
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
): Promise<{ ok: true; value: Buffer } | { ok: false; error: string }> {
return await new Promise((resolve) => {
let done = false;
let total = 0;
@ -83,17 +89,7 @@ export async function readJsonBody(
req.on("end", () => {
if (done) return;
done = true;
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) {
resolve({ ok: true, value: {} });
return;
}
try {
const parsed = JSON.parse(raw) as unknown;
resolve({ ok: true, value: parsed });
} catch (err) {
resolve({ ok: false, error: String(err) });
}
resolve({ ok: true, value: Buffer.concat(chunks) });
});
req.on("error", (err) => {
if (done) return;
@ -103,6 +99,97 @@ export async function readJsonBody(
});
}
export function parseJsonBody(
raw: Buffer,
): { ok: true; value: unknown } | { ok: false; error: string } {
const str = raw.toString("utf-8").trim();
if (!str) {
return { ok: true, value: {} };
}
try {
const parsed = JSON.parse(str) as unknown;
return { ok: true, value: parsed };
} catch (err) {
return { ok: false, error: String(err) };
}
}
export async function readJsonBody(
req: IncomingMessage,
maxBytes: number,
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
const raw = await readRawBody(req, maxBytes);
if (!raw.ok) return raw;
return parseJsonBody(raw.value);
}
export type AuthenticateHookContext = {
req: IncomingMessage;
url: URL;
subPath: string;
headers: Record<string, string>;
rawBody: Buffer;
transform: HookMappingTransformResolved | undefined;
expectedToken: string;
};
export type AuthenticateHookResult =
| { ok: true; tokenFromQuery: boolean }
| { ok: false; status: number; error: string };
/**
* Authenticate a webhook request.
*
* If the mapping has a verifyAuth export, use custom auth.
* Otherwise, fall back to standard token auth.
*/
export async function authenticateHook(
ctx: AuthenticateHookContext,
): Promise<AuthenticateHookResult> {
if (ctx.transform) {
const result = await verifyFromMapping(ctx.transform, ctx);
if (result) return result;
}
return verifyFromToken(ctx.req, ctx.url, ctx.expectedToken);
}
async function verifyFromMapping(
transform: HookMappingTransformResolved,
ctx: Pick<AuthenticateHookContext, "headers" | "url" | "subPath" | "rawBody">,
): Promise<AuthenticateHookResult | undefined> {
let verifyAuth: HookVerifyAuthFn | undefined;
try {
verifyAuth = await loadVerifyAuth(transform);
} catch (err) {
return { ok: false, status: 500, error: `Failed to load verifyAuth: ${String(err)}` };
}
if (!verifyAuth) return undefined;
try {
const ok = await verifyAuth({
headers: ctx.headers,
url: ctx.url,
path: ctx.subPath,
rawBody: ctx.rawBody,
});
if (!ok) return { ok: false, status: 401, error: "Unauthorized" };
return { ok: true, tokenFromQuery: false };
} catch (err) {
return { ok: false, status: 401, error: `verifyAuth error: ${String(err)}` };
}
}
function verifyFromToken(
req: IncomingMessage,
url: URL,
expectedToken: string,
): AuthenticateHookResult {
const { token, fromQuery } = extractHookToken(req, url);
if (!token || token !== expectedToken) {
return { ok: false, status: 401, error: "Unauthorized" };
}
return { ok: true, tokenFromQuery: fromQuery };
}
export function normalizeHookHeaders(req: IncomingMessage) {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {

View File

@ -15,18 +15,19 @@ import { handleSlackHttpRequest } from "../slack/http/index.js";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
import {
extractHookToken,
authenticateHook,
getHookChannelError,
type HookMessageChannel,
type HooksConfigResolved,
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
readJsonBody,
parseJsonBody,
readRawBody,
resolveHookChannel,
resolveHookDeliver,
} from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js";
import { applyMapping, findMapping } from "./hooks-mapping.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
@ -76,21 +77,6 @@ export function createHooksRequestHandler(
return false;
}
const { token, fromQuery } = extractHookToken(req, url);
if (!token || token !== hooksConfig.token) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
if (fromQuery) {
logHooks.warn(
"Hook token provided via query parameter is deprecated for security reasons. " +
"Tokens in URLs appear in logs, browser history, and referrer headers. " +
"Use Authorization: Bearer <token> or X-Moltbot-Token header instead.",
);
}
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
@ -107,15 +93,58 @@ export function createHooksRequestHandler(
return true;
}
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
if (!body.ok) {
const status = body.error === "payload too large" ? 413 : 400;
sendJson(res, status, { ok: false, error: body.error });
const headers = normalizeHookHeaders(req);
// Read and parse body
const rawBody = await readRawBody(req, hooksConfig.maxBodyBytes);
if (!rawBody.ok) {
const status = rawBody.error === "payload too large" ? 413 : 400;
sendJson(res, status, { ok: false, error: rawBody.error });
return true;
}
const parsed = parseJsonBody(rawBody.value);
if (!parsed.ok) {
sendJson(res, 400, { ok: false, error: parsed.error });
return true;
}
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
const headers = normalizeHookHeaders(req);
const payload =
typeof parsed.value === "object" && parsed.value !== null
? (parsed.value as Record<string, unknown>)
: {};
// Find matching mapping
const matched = findMapping(hooksConfig.mappings, { payload, headers, url, path: subPath });
// Authenticate (custom verifyAuth or token)
const auth = await authenticateHook({
req,
url,
subPath,
headers,
rawBody: rawBody.value,
transform: matched?.transform,
expectedToken: hooksConfig.token,
});
if (!auth.ok) {
if (auth.status === 401) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
} else {
sendJson(res, auth.status, { ok: false, error: auth.error });
}
return true;
}
if (auth.tokenFromQuery) {
logHooks.warn(
"Hook token provided via query parameter is deprecated for security reasons. " +
"Tokens in URLs appear in logs, browser history, and referrer headers. " +
"Use Authorization: Bearer <token> or X-Moltbot-Token header instead.",
);
}
if (subPath === "wake") {
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
@ -139,9 +168,9 @@ export function createHooksRequestHandler(
return true;
}
if (hooksConfig.mappings.length > 0) {
if (matched) {
try {
const mapped = await applyHookMappings(hooksConfig.mappings, {
const mapped = await applyMapping(matched, {
payload: payload as Record<string, unknown>,
headers,
url,