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:
1 parent
f97ca8565a
commit
1c9739d1da
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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