feat: add authentication support to ntfy notifications

ntfy servers that require auth rejected every Checkmate alert because the
provider posted without an Authorization header, so alerts to protected
topics failed silently.

Adds ntfyAuthType ("none" | "token" | "basic"):
- token: Authorization: Bearer <accessToken>
- basic: Authorization: Basic base64(ntfyUsername:accessToken)

Both types store their secret in the existing accessToken field rather than
introducing a new secret, so credential handling stays in one place.

Incomplete credentials fail the send instead of falling back to an anonymous
post, and validation rejects partial auth config up front so a channel can't
be saved looking authenticated while posting anonymously.

Backend only; frontend follows once this lands.

Closes #3717
This commit is contained in:
egeoztass committed 2026-08-14 14:01:06 +03:00
1 parent f97ca8565a
commit 1c9739d1da
8 files changed
+289 -8

No files matched your search

@@ -1,9 +1,44 @@
import { z } from "zod";
import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js";
//****************************************
// Notification Validations
//****************************************
// ntfy stores its secret in accessToken for both auth types: the bearer token for
// "token", the password for "basic". Reject partially filled credentials so a channel
// can't be saved looking authenticated while posting anonymously.
const refineNtfyAuth = (body: { ntfyAuthType?: string; ntfyUsername?: string; accessToken?: string }, ctx: z.RefinementCtx) => {
const authType = body.ntfyAuthType ?? "none";
if (authType === "none") {
if (body.accessToken) {
ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Select an authentication type to use an access token" });
}
if (body.ntfyUsername) {
ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Select an authentication type to use a username" });
}
return;
}
if (authType === "token") {
if (!body.accessToken) {
ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Access token is required for token authentication" });
}
if (body.ntfyUsername) {
ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is only used with basic authentication" });
}
return;
}
if (!body.ntfyUsername) {
ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is required for basic authentication" });
}
if (!body.accessToken) {
ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Password is required for basic authentication" });
}
};
export const createNotificationBodyValidation = z.discriminatedUnion("type", [
// Email notification
z.object({
@@ -98,12 +133,17 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
}),
// ntfy notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("ntfy"),
address: z.url({ message: "Please enter a valid ntfy server URL" }),
topic: z.string().min(1, "Topic is required"),
}),
z
.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("ntfy"),
address: z.url({ message: "Please enter a valid ntfy server URL" }),
topic: z.string().min(1, "Topic is required"),
ntfyAuthType: z.enum(NtfyAuthTypes).optional(),
ntfyUsername: z.union([z.string(), z.literal("")]).optional(),
accessToken: z.union([z.string(), z.literal("")]).optional(),
})
.superRefine(refineNtfyAuth),
]);
export const testNotificationBodyValidation = createNotificationBodyValidation;