From 8c02e6edcd9202fbcc78af67d299695432e1f3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jannik=20B=C3=A4cker?= Date: Mon, 27 Jul 2026 00:31:47 +0000 Subject: [PATCH 1/4] feat: add bearer/basic auth support to webhook notifications Add optional authentication to webhook notifications: - webhookAuthType field ('none' | 'basic' | 'bearer') - Basic Auth: base64-encoded username:password header - Bearer Token: Authorization header with token Closes #2369 --- .../domain/notifications/notification.type.ts | 14 ++++--- .../domain/notifications/providers/webhook.ts | 33 ++++++++++++++- .../notifications/webhookProvider.test.ts | 42 ++++++++++++++----- 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 8ae617a883..3aa44ca8fe 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -27,10 +27,17 @@ export interface Notification { accountSid?: string; twilioPhoneNumber?: string; topic?: string; + // Webhook authentication fields + webhookAuthType?: WebhookAuthType; + webhookAuthUsername?: string; + webhookAuthPassword?: string; + webhookAuthToken?: string; createdAt: string; updatedAt: string; } +export type WebhookAuthType = "none" | "basic" | "bearer"; + export interface AlertPagerDutyPayload { routing_key?: string; dedup_key?: string; @@ -57,11 +64,6 @@ export interface AlertDiscordPayload { timestamp: string; } -/** - * Unified notification message types for cross-provider consistency - * Part of notification system unification effort - */ - export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test"; export type NotificationSeverity = "critical" | "warning" | "info" | "success"; @@ -79,7 +81,7 @@ export interface ThresholdBreach { currentValue: number; threshold: number; unit: string; - formattedValue: string; // e.g., "85%" or "72°C" + formattedValue: string; } export interface IncidentInfo { diff --git a/server/src/domain/notifications/providers/webhook.ts b/server/src/domain/notifications/providers/webhook.ts index fc5a80fdeb..b0403ccb27 100644 --- a/server/src/domain/notifications/providers/webhook.ts +++ b/server/src/domain/notifications/providers/webhook.ts @@ -1,5 +1,5 @@ const SERVICE_NAME = "WebhookProvider"; -import type { Notification } from "@/domain/notifications/notification.type.js"; +import type { Notification, WebhookAuthType } from "@/domain/notifications/notification.type.js"; import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; import { getTestMessage } from "@/domain/notifications/providers/utils.js"; @@ -19,6 +19,7 @@ export class WebhookProvider extends NotificationProvider { json: payload, headers: { "Content-Type": "application/json", + ...this.getAuthHeaders(notification), }, ...this.gotRequestOptions(), }); @@ -40,6 +41,35 @@ export class WebhookProvider extends NotificationProvider { } }; + /** + * Builds authorization headers based on the notification's webhookAuthType. + * Supports Basic Auth (base64-encoded username:password) and Bearer tokens. + */ + private getAuthHeaders = (notification: Notification): Record => { + const authType: WebhookAuthType = notification.webhookAuthType || "none"; + + switch (authType) { + case "basic": { + if (notification.webhookAuthUsername && notification.webhookAuthPassword) { + const encoded = Buffer.from( + `${notification.webhookAuthUsername}:${notification.webhookAuthPassword}` + ).toString("base64"); + return { Authorization: `Basic ${encoded}` }; + } + return {}; + } + case "bearer": { + if (notification.webhookAuthToken) { + return { Authorization: `Bearer ${notification.webhookAuthToken}` }; + } + return {}; + } + case "none": + default: + return {}; + } + }; + private buildWebhookPayload(message: NotificationMessage): object { const lines: string[] = []; @@ -101,6 +131,7 @@ export class WebhookProvider extends NotificationProvider { json: { text: getTestMessage() }, headers: { "Content-Type": "application/json", + ...this.getAuthHeaders(notification as Notification), }, ...this.gotRequestOptions(), }); diff --git a/server/test/unit/providers/notifications/webhookProvider.test.ts b/server/test/unit/providers/notifications/webhookProvider.test.ts index fd16f2c060..fad86b6f3b 100644 --- a/server/test/unit/providers/notifications/webhookProvider.test.ts +++ b/server/test/unit/providers/notifications/webhookProvider.test.ts @@ -86,17 +86,39 @@ describe("WebhookProvider", () => { expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident"); }); - it("omits threshold and incident sections when not present", async () => { + it("sends Authorization header with Basic auth", async () => { const { provider } = createProvider(); - const msg = makeMessage(); - msg.content.thresholds = undefined; - msg.content.details = undefined; - msg.content.incident = undefined; - await provider.sendMessage(makeNotification() as any, msg); - const text = mockGotPost.mock.calls[0][1].json.text; - expect(text).not.toContain("Threshold"); - expect(text).not.toContain("Additional Information"); - expect(text).not.toContain("View Incident"); + const notification = makeNotification({ + webhookAuthType: "basic", + webhookAuthUsername: "admin", + webhookAuthPassword: "secret", + }); + await provider.sendMessage(notification as any, makeMessage()); + const headers = mockGotPost.mock.calls[0][1].headers; + expect(headers.Authorization).toBe(`Basic ${Buffer.from("admin:secret").toString("base64")}`); + }); + + it("sends Authorization header with Bearer auth", async () => { + const { provider } = createProvider(); + const notification = makeNotification({ + webhookAuthType: "bearer", + webhookAuthToken: "tok_abc123", + }); + await provider.sendMessage(notification as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].headers.Authorization).toBe("Bearer tok_abc123"); + }); + + it("does not send Authorization header when authType is none", async () => { + const { provider } = createProvider(); + const notification = makeNotification({ webhookAuthType: "none" }); + await provider.sendMessage(notification as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].headers.Authorization).toBeUndefined(); + }); + + it("does not send Authorization header when authType is missing (defaults to none)", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].headers.Authorization).toBeUndefined(); }); }); }); From 782d5434d4b8ac8641bb342792cf05415c3ba94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jannik=20B=C3=A4cker?= Date: Mon, 27 Jul 2026 00:44:37 +0000 Subject: [PATCH 2/4] style: apply prettier formatting to webhook provider --- server/src/domain/notifications/providers/webhook.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/domain/notifications/providers/webhook.ts b/server/src/domain/notifications/providers/webhook.ts index b0403ccb27..92ed8620f6 100644 --- a/server/src/domain/notifications/providers/webhook.ts +++ b/server/src/domain/notifications/providers/webhook.ts @@ -51,9 +51,7 @@ export class WebhookProvider extends NotificationProvider { switch (authType) { case "basic": { if (notification.webhookAuthUsername && notification.webhookAuthPassword) { - const encoded = Buffer.from( - `${notification.webhookAuthUsername}:${notification.webhookAuthPassword}` - ).toString("base64"); + const encoded = Buffer.from(`${notification.webhookAuthUsername}:${notification.webhookAuthPassword}`).toString("base64"); return { Authorization: `Basic ${encoded}` }; } return {}; From e7b2428019a0ec236d57d04690f1f59ca55818de Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 15 Aug 2026 23:54:35 +0000 Subject: [PATCH 3/4] feat(webhook): add client UI, validation and persistence for webhook auth Completes bearer/basic auth support for webhook notifications (Closes #2369) Signed-off-by: OpenClaw --- client/src/Hooks/useNotificationForm.ts | 4 + .../src/Pages/Notifications/create/index.tsx | 86 +++++++++++++++++++ client/src/Types/Notification.ts | 4 + client/src/Validation/notifications.ts | 4 + client/src/locales/en.json | 14 +++ .../api/validation/notificationValidation.ts | 30 +++++++ .../notifications/notification.model.ts | 8 ++ 7 files changed, 150 insertions(+) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0051ddafdf..642280fd4e 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -44,6 +44,10 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "webhook", notificationName: data.notificationName || "", address: data.address || "", + webhookAuthType: data.webhookAuthType || "none", + webhookAuthUsername: data.webhookAuthUsername || "", + webhookAuthPassword: data.webhookAuthPassword || "", + webhookAuthToken: data.webhookAuthToken || "", }; } if (data?.type === "pager_duty") { diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 84d2646168..539b375d72 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -388,6 +388,92 @@ const NotificationsCreatePage = () => { } /> )} + {watchedType === "webhook" && ( + + ( + + )} + /> + {watch("webhookAuthType") === "basic" && ( + <> + ( + + )} + /> + ( + + )} + /> + + )} + {watch("webhookAuthType") === "bearer" && ( + ( + + )} + /> + )} + + } + /> + )} {watchedType === "matrix" && ( { + if (data.webhookAuthType === "basic") { + if (!data.webhookAuthUsername) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Username is required when auth type is Basic", + path: ["webhookAuthUsername"], + }); + } + if (!data.webhookAuthPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password is required when auth type is Basic", + path: ["webhookAuthPassword"], + }); + } + } + if (data.webhookAuthType === "bearer") { + if (!data.webhookAuthToken) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Token is required when auth type is Bearer", + path: ["webhookAuthToken"], + }); + } + } }), // Slack notification z.object({ diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 797b3e7688..a4e8d81cc5 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -52,6 +52,14 @@ const NotificationSchema = new Schema( accountSid: { type: String }, twilioPhoneNumber: { type: String }, topic: { type: String }, + webhookAuthType: { + type: String, + enum: ["none", "basic", "bearer"], + default: "none", + }, + webhookAuthUsername: { type: String }, + webhookAuthPassword: { type: String }, + webhookAuthToken: { type: String }, }, { timestamps: true, From d0655aca129baaf48052701b6a845c2f1c27fb25 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 16 Aug 2026 00:01:29 +0000 Subject: [PATCH 4/4] fix(webhook): align defaults with rocket_chat type and fix formatting Signed-off-by: OpenClaw --- client/src/Hooks/useNotificationForm.ts | 7 -- .../src/Pages/Notifications/create/index.tsx | 35 +++++++-- .../api/validation/notificationValidation.ts | 74 ++++++++++--------- 3 files changed, 65 insertions(+), 51 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 574f471904..478e7e908f 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -95,13 +95,6 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: data?.type ?? "email", notificationName: data?.notificationName || "", address: data?.address || "", - accessToken: data?.accessToken || "", - accountSid: data?.accountSid || "", - phone: data?.phone || "", - twilioPhoneNumber: data?.twilioPhoneNumber || "", - homeserverUrl: data?.homeserverUrl || "", - roomId: data?.roomId || "", - topic: data?.topic || "", }; } diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 6848dbd681..b7df141f91 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -268,23 +268,40 @@ const NotificationsCreatePage = () => { name="webhookAuthType" fieldLabel={t("pages.notifications.form.webhookAuth.optionAuthType")} options={[ - { value: "none", label: t("pages.notifications.form.webhookAuth.typeNone") }, - { value: "basic", label: t("pages.notifications.form.webhookAuth.typeBasic") }, - { value: "bearer", label: t("pages.notifications.form.webhookAuth.typeBearer") }, + { + value: "none", + label: t("pages.notifications.form.webhookAuth.typeNone"), + }, + { + value: "basic", + label: t("pages.notifications.form.webhookAuth.typeBasic"), + }, + { + value: "bearer", + label: t("pages.notifications.form.webhookAuth.typeBearer"), + }, ]} /> {watch("webhookAuthType") === "basic" && ( <> )} @@ -293,7 +310,9 @@ const NotificationsCreatePage = () => { name="webhookAuthToken" type="password" fieldLabel={t("pages.notifications.form.webhookAuth.optionToken")} - placeholder={t("pages.notifications.form.webhookAuth.placeholderToken")} + placeholder={t( + "pages.notifications.form.webhookAuth.placeholderToken" + )} /> )} diff --git a/server/src/api/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts index f46e50459e..cc5d37916f 100644 --- a/server/src/api/validation/notificationValidation.ts +++ b/server/src/api/validation/notificationValidation.ts @@ -15,44 +15,46 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ accessToken: z.union([z.string(), z.literal("")]).optional(), }), // Webhook notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("webhook"), - address: z.url({ message: "Please enter a valid Webhook URL" }), - homeserverUrl: z.union([z.string(), z.literal("")]).optional(), - roomId: z.union([z.string(), z.literal("")]).optional(), - accessToken: z.union([z.string(), z.literal("")]).optional(), - webhookAuthType: z.enum(["none", "basic", "bearer"]).optional(), - webhookAuthUsername: z.union([z.string(), z.literal("")]).optional(), - webhookAuthPassword: z.union([z.string(), z.literal("")]).optional(), - webhookAuthToken: z.union([z.string(), z.literal("")]).optional(), - }).superRefine((data, ctx) => { - if (data.webhookAuthType === "basic") { - if (!data.webhookAuthUsername) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Username is required when auth type is Basic", - path: ["webhookAuthUsername"], - }); + z + .object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("webhook"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + webhookAuthType: z.enum(["none", "basic", "bearer"]).optional(), + webhookAuthUsername: z.union([z.string(), z.literal("")]).optional(), + webhookAuthPassword: z.union([z.string(), z.literal("")]).optional(), + webhookAuthToken: z.union([z.string(), z.literal("")]).optional(), + }) + .superRefine((data, ctx) => { + if (data.webhookAuthType === "basic") { + if (!data.webhookAuthUsername) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Username is required when auth type is Basic", + path: ["webhookAuthUsername"], + }); + } + if (!data.webhookAuthPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password is required when auth type is Basic", + path: ["webhookAuthPassword"], + }); + } } - if (!data.webhookAuthPassword) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Password is required when auth type is Basic", - path: ["webhookAuthPassword"], - }); + if (data.webhookAuthType === "bearer") { + if (!data.webhookAuthToken) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Token is required when auth type is Bearer", + path: ["webhookAuthToken"], + }); + } } - } - if (data.webhookAuthType === "bearer") { - if (!data.webhookAuthToken) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Token is required when auth type is Bearer", - path: ["webhookAuthToken"], - }); - } - } - }), + }), // Rocket.Chat notification z.object({ notificationName: z.string().min(1, "Notification name is required"),