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:
parent
718bc3f9c8
commit
e7c367ab3c
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user