Merge pull request #3839 from egeoztass/feat/ntfy-authentication
feat: add authentication support to ntfy notifications
This commit is contained in:
8 files changed
+289
-8
No files matched your search
+29
-1
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user