feat(hooks): support custom verifyAuth in transform modules

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

When a transform module exports verifyAuth, it runs BEFORE standard
token auth. This enables:

- GitHub webhook signature verification
- Stripe webhook signature verification
- Any external service with custom auth schemes

Changes:
- hooks.ts: Split readJsonBody into readRawBody + parseJsonBody
- hooks-mapping.ts: Add HookVerifyAuthContext type, findMappingVerifyAuth()
- server-http.ts: Check for custom auth before token auth
- webhook.md: Document verifyAuth with GitHub example

Closes: discussion about GitHub webhooks
This commit is contained in:
Bradley Priest 2026-01-29 20:23:54 +13:00
parent 718bc3f9c8
commit e7c367ab3c
4 changed files with 246 additions and 41 deletions

View File

@ -145,6 +145,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
```typescript
// hooks/github-transform.ts
import { createHmac, timingSafeEqual } from "crypto";
import type { HookVerifyAuthContext, HookMappingContext } from "moltbot";
// Runs BEFORE token auth - return true to allow, false to reject
export function verifyAuth(ctx: HookVerifyAuthContext): boolean {
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: HookMappingContext) {
const event = ctx.headers["x-github-event"];
const payload = ctx.payload as Record<string, unknown>;
// Format based on event type
if (event === "push") {
return { message: `Push to ${payload.repository?.full_name}: ${payload.head_commit?.message}` };
}
if (event === "pull_request") {
return { message: `PR ${payload.action}: ${payload.pull_request?.title}` };
}
return { message: `GitHub ${event}: ${JSON.stringify(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.ts
```
### HookVerifyAuthContext
The `verifyAuth` function receives:
- `headers`: Record of lowercase header names to values
- `url`: The parsed URL object
- `path`: The subpath (e.g., "github" for `/hooks/github`)
- `rawBody`: The raw request body as a Buffer (for signature computation)
### Async verifyAuth
`verifyAuth` can be async if needed:
```typescript
export async function verifyAuth(ctx: HookVerifyAuthContext): Promise<boolean> {
// 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

@ -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"];
@ -293,14 +316,51 @@ 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 verifyAuth =
typeof mod.verifyAuth === "function" ? (mod.verifyAuth as HookVerifyAuthFn) : undefined;
const result: CachedTransform = { transform: fn, verifyAuth };
transformCache.set(transform.modulePath, result);
return result;
}
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
const cached = await loadTransformModule(transform);
return cached.transform;
}
/**
* Find the first mapping that matches the given path and has a custom verifyAuth function.
* Returns the verifyAuth function if found, null otherwise.
*
* This is called BEFORE normal token auth to allow mappings to implement
* custom authentication (e.g., GitHub webhook HMAC signatures).
*/
export async function findMappingVerifyAuth(
mappings: HookMappingResolved[],
subPath: string,
): Promise<HookVerifyAuthFn | null> {
const normalizedPath = normalizeMatchPath(subPath);
for (const mapping of mappings) {
// Only match on path for pre-auth check (no payload available yet)
if (mapping.matchPath && mapping.matchPath !== normalizedPath) continue;
if (!mapping.matchPath && !mapping.matchSource) continue; // needs at least one matcher
if (mapping.transform) {
const mod = await loadTransformModule(mapping.transform);
if (mod.verifyAuth) {
return mod.verifyAuth;
}
}
}
return null;
}
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {

View File

@ -61,10 +61,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 +83,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 +93,30 @@ 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 function normalizeHookHeaders(req: IncomingMessage) {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {

View File

@ -22,11 +22,13 @@ import {
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
parseJsonBody,
readJsonBody,
readRawBody,
resolveHookChannel,
resolveHookDeliver,
} from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js";
import { applyHookMappings, findMappingVerifyAuth } 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 +78,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,16 +94,75 @@ 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 });
return true;
}
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
const headers = normalizeHookHeaders(req);
// Check for custom auth via mapping transform's verifyAuth export
// This runs BEFORE normal token auth to support external webhook signatures (e.g., GitHub HMAC)
let payload: Record<string, unknown> = {};
const customVerifyAuth = await findMappingVerifyAuth(hooksConfig.mappings, subPath);
if (customVerifyAuth) {
// Read raw body for signature verification
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;
}
// Call custom verifyAuth function
const authCtx = { headers, url, path: subPath, rawBody: rawBody.value };
let authResult: boolean;
try {
authResult = await customVerifyAuth(authCtx);
} catch (err) {
logHooks.warn(`custom verifyAuth failed: ${String(err)}`);
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
if (!authResult) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
// Auth passed, parse the JSON body from the raw buffer
const parsed = parseJsonBody(rawBody.value);
if (!parsed.ok) {
sendJson(res, 400, { ok: false, error: parsed.error });
return true;
}
payload = typeof parsed.value === "object" && parsed.value !== null ? parsed.value : {};
} else {
// Normal token auth
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.",
);
}
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 });
return true;
}
payload = typeof body.value === "object" && body.value !== null ? body.value : {};
}
if (subPath === "wake") {
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
if (!normalized.ok) {