From 1c9739d1da1e7a19d38b9101fb7e95853c5040af Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 14 Aug 2026 14:01:06 +0300 Subject: [PATCH] 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 - 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 --- server/openapi.json | 30 ++++++- server/openapi/routes/notification.ts | 9 +- .../api/validation/notificationValidation.ts | 52 ++++++++++-- .../notifications/notification.model.ts | 3 + .../domain/notifications/notification.type.ts | 7 ++ .../domain/notifications/providers/ntfy.ts | 48 +++++++++++ .../notifications/ntfyProvider.test.ts | 83 +++++++++++++++++++ .../validation/notificationValidation.test.ts | 65 +++++++++++++++ 8 files changed, 289 insertions(+), 8 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 627354c55..22498d691 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -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" } } }, diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index 7da45adfb..5950ab4a3 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -78,7 +78,14 @@ const notificationVariantMeta: Record { + 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; diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 518a4fc3f..b2dce9ebe 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -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 { _id: Types.ObjectId; @@ -53,6 +54,8 @@ const NotificationSchema = new Schema( accountSid: { type: String }, twilioPhoneNumber: { type: String }, topic: { type: String }, + ntfyAuthType: { type: String, enum: NtfyAuthTypes }, + ntfyUsername: { type: String }, }, { timestamps: true, diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 7c4c9f75d..a45d4c4f8 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -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; } diff --git a/server/src/domain/notifications/providers/ntfy.ts b/server/src/domain/notifications/providers/ntfy.ts index 7ca351d87..b415f648b 100644 --- a/server/src/domain/notifications/providers/ntfy.ts +++ b/server/src/domain/notifications/providers/ntfy.ts @@ -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, method: string): Record | 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, diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts index ffd95a0d7..d365584a1 100644 --- a/server/test/unit/providers/notifications/ntfyProvider.test.ts +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -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(); + }); + }); }); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts index 5cb2c1d59..2dd7ffc95 100644 --- a/server/test/unit/validation/notificationValidation.test.ts +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -44,4 +44,69 @@ describe("notification validation", () => { expect(result.success).toBe(false); } ); + + describe("ntfy authentication", () => { + const parseNtfy = (overrides: Record) => + createNotificationBodyValidation.safeParse({ + notificationName: "ntfy alerts", + type: "ntfy", + address: "https://ntfy.example.com", + topic: "checkmate-alerts", + ...overrides, + }); + + const pathsOf = (result: ReturnType) => (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); + }); + }); });