Merge pull request #3839 from egeoztass/feat/ntfy-authentication

feat: add authentication support to ntfy notifications
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-08-18 10:30:08 -07:00
commit 5cca0fb2d3
8 files changed
+289 -8

No files matched your search

+29 -1
View File
@@ -1410,6 +1410,32 @@
"topic": {
"type": "string",
"minLength": 1
},
"ntfyAuthType": {
"type": "string",
"enum": ["none", "token", "basic"]
},
"ntfyUsername": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"accessToken": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
}
},
"required": ["notificationName", "type", "address", "topic"],
@@ -1417,7 +1443,9 @@
"notificationName": "ntfy topic",
"type": "ntfy",
"address": "https://ntfy.sh",
"topic": "checkmate-alerts"
"topic": "checkmate-alerts",
"ntfyAuthType": "token",
"accessToken": "tk_your-ntfy-access-token"
}
},
"PublicStatusPagePayload": {
+8 -1
View File
@@ -78,7 +78,14 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
},
ntfy: {
component: "NtfyNotification",
example: { notificationName: "ntfy topic", type: "ntfy", address: "https://ntfy.sh", topic: "checkmate-alerts" },
example: {
notificationName: "ntfy topic",
type: "ntfy",
address: "https://ntfy.sh",
topic: "checkmate-alerts",
ntfyAuthType: "token",
accessToken: "tk_your-ntfy-access-token",
},
},
};
@@ -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;
@@ -1,5 +1,6 @@
import { Schema, model, type Types } from "mongoose";
import type { Notification, NotificationChannel } from "@/domain/notifications/notification.type.js";
import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js";
interface NotificationDocument extends Omit<Notification, "id" | "userId" | "teamId" | "createdAt" | "updatedAt"> {
_id: Types.ObjectId;
@@ -53,6 +54,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
accountSid: { type: String },
twilioPhoneNumber: { type: String },
topic: { type: String },
ntfyAuthType: { type: String, enum: NtfyAuthTypes },
ntfyUsername: { type: String },
},
{
timestamps: true,
@@ -14,6 +14,11 @@ export const NotificationChannels = [
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
// ntfy servers accept either a bearer access token or HTTP basic credentials.
// Both store their secret in `accessToken`; basic auth pairs it with `ntfyUsername`.
export const NtfyAuthTypes = ["none", "token", "basic"] as const;
export type NtfyAuthType = (typeof NtfyAuthTypes)[number];
export interface Notification {
id: string;
userId: string;
@@ -28,6 +33,8 @@ export interface Notification {
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
ntfyAuthType?: NtfyAuthType;
ntfyUsername?: string;
createdAt: string;
updatedAt: string;
}
@@ -11,12 +11,18 @@ export class NtfyProvider extends NotificationProvider {
return false;
}
const authHeaders = this.buildAuthHeaders(notification, "sendTestAlert");
if (authHeaders === null) {
return false;
}
try {
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
body: getTestMessage(),
headers: {
Title: "Checkmate test notification",
Priority: "default",
...authHeaders,
},
...this.gotRequestOptions(),
});
@@ -38,6 +44,11 @@ export class NtfyProvider extends NotificationProvider {
return false;
}
const authHeaders = this.buildAuthHeaders(notification, "sendMessage");
if (authHeaders === null) {
return false;
}
try {
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
body: this.buildNtfyText(message),
@@ -45,6 +56,7 @@ export class NtfyProvider extends NotificationProvider {
Title: message.content.title,
Priority: this.mapPriority(message.severity),
Tags: this.mapTags(message.severity),
...authHeaders,
},
...this.gotRequestOptions(),
});
@@ -70,6 +82,42 @@ export class NtfyProvider extends NotificationProvider {
return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`;
}
/**
* Returns the Authorization header for the configured auth type, or null when the
* credentials are incomplete. Null is treated as a send failure rather than falling
* back to an anonymous post, so a misconfigured channel surfaces instead of quietly
* publishing to a topic the server may leave world-readable.
*/
private buildAuthHeaders(notification: Partial<Notification>, method: string): Record<string, string> | null {
const { ntfyAuthType, accessToken, ntfyUsername } = notification;
if (!ntfyAuthType || ntfyAuthType === "none") {
return {};
}
if (ntfyAuthType === "token") {
if (!accessToken) {
this.logMissingCredentials("access token", method);
return null;
}
return { Authorization: `Bearer ${accessToken}` };
}
if (!ntfyUsername || !accessToken) {
this.logMissingCredentials("username and password", method);
return null;
}
return { Authorization: `Basic ${Buffer.from(`${ntfyUsername}:${accessToken}`).toString("base64")}` };
}
private logMissingCredentials(missing: string, method: string): void {
this.logger.warn({
message: `ntfy notification is configured for authentication but is missing its ${missing}`,
service: SERVICE_NAME,
method,
});
}
private buildNtfyText(message: NotificationMessage): string {
const lines = [
message.content.summary,
@@ -109,4 +109,87 @@ describe("NtfyProvider", () => {
expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1");
});
});
describe("authentication", () => {
const authHeaderOf = () => mockGotPost.mock.calls[0][1].headers.Authorization;
it("sends no Authorization header when no auth type is configured", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNtfyNotification() as any, makeMessage());
expect(authHeaderOf()).toBeUndefined();
});
it("sends no Authorization header when auth type is none", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNtfyNotification({ ntfyAuthType: "none" }) as any, makeMessage());
expect(authHeaderOf()).toBeUndefined();
});
it("sends a bearer token for token auth", async () => {
const { provider } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" });
expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true);
expect(authHeaderOf()).toBe("Bearer tk_secret");
});
it("sends base64 encoded credentials for basic auth", async () => {
const { provider } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" });
expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true);
expect(authHeaderOf()).toBe(`Basic ${Buffer.from("alice:s3cret").toString("base64")}`);
});
it("authenticates test alerts as well as alerts", async () => {
const { provider } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" });
expect(await provider.sendTestAlert(notification)).toBe(true);
expect(authHeaderOf()).toBe("Bearer tk_secret");
});
it("preserves the notification headers alongside the Authorization header", async () => {
const { provider } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" });
await provider.sendMessage(notification as any, makeMessage());
expect(mockGotPost.mock.calls[0][1].headers).toEqual(
expect.objectContaining({
Title: "Monitor Down: Test Monitor",
Priority: "high",
Tags: "rotating_light",
Authorization: "Bearer tk_secret",
})
);
});
// A misconfigured channel must fail loudly rather than post anonymously to a topic
// the operator believes is protected.
it("refuses to send unauthenticated when token auth has no access token", async () => {
const { provider, logger } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" });
expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false);
expect(mockGotPost).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
it("refuses to send unauthenticated when basic auth has no username", async () => {
const { provider, logger } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "basic", accessToken: "s3cret" });
expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false);
expect(mockGotPost).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
it("refuses to send unauthenticated when basic auth has no password", async () => {
const { provider, logger } = createProvider();
const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "" });
expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false);
expect(mockGotPost).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
it("refuses to send an unauthenticated test alert when credentials are incomplete", async () => {
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }))).toBe(false);
expect(mockGotPost).not.toHaveBeenCalled();
});
});
});
@@ -44,4 +44,69 @@ describe("notification validation", () => {
expect(result.success).toBe(false);
}
);
describe("ntfy authentication", () => {
const parseNtfy = (overrides: Record<string, unknown>) =>
createNotificationBodyValidation.safeParse({
notificationName: "ntfy alerts",
type: "ntfy",
address: "https://ntfy.example.com",
topic: "checkmate-alerts",
...overrides,
});
const pathsOf = (result: ReturnType<typeof parseNtfy>) => (result.success ? [] : result.error.issues.map((issue) => issue.path.join(".")));
it("accepts an unauthenticated channel", () => {
expect(parseNtfy({}).success).toBe(true);
});
it("accepts an explicit auth type of none", () => {
expect(parseNtfy({ ntfyAuthType: "none" }).success).toBe(true);
});
it("accepts token auth with an access token", () => {
expect(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret" }).success).toBe(true);
});
it("accepts basic auth with a username and password", () => {
expect(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }).success).toBe(true);
});
it("rejects an unknown auth type", () => {
expect(parseNtfy({ ntfyAuthType: "oauth" }).success).toBe(false);
});
it("rejects token auth without an access token", () => {
expect(pathsOf(parseNtfy({ ntfyAuthType: "token" }))).toContain("accessToken");
});
it("rejects token auth carrying a username", () => {
expect(pathsOf(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret", ntfyUsername: "alice" }))).toContain("ntfyUsername");
});
it("rejects basic auth without a username", () => {
expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", accessToken: "s3cret" }))).toContain("ntfyUsername");
});
it("rejects basic auth without a password", () => {
expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice" }))).toContain("accessToken");
});
it("reports both fields when basic auth is entirely empty", () => {
expect(pathsOf(parseNtfy({ ntfyAuthType: "basic" }))).toEqual(expect.arrayContaining(["ntfyUsername", "accessToken"]));
});
// Credentials with auth off would be stored but never sent, which reads as protected
// while posting anonymously.
it("rejects credentials supplied without an auth type", () => {
expect(pathsOf(parseNtfy({ accessToken: "tk_secret" }))).toContain("accessToken");
expect(pathsOf(parseNtfy({ ntfyUsername: "alice" }))).toContain("ntfyUsername");
});
it("still requires a topic and a valid server URL", () => {
expect(parseNtfy({ topic: "" }).success).toBe(false);
expect(parseNtfy({ address: "not-a-url" }).success).toBe(false);
});
});
});