feat(notifications): generate VAPID keys from the admin panel

Add a server-side VAPID keypair generator so a self-hoster no longer has
to run a shell command to enable Web Push. A new admin-only route
(POST /api/admin/settings/web-push-vapid/generate) calls web-push's
generateVAPIDKeys(), stores the private key encrypted at rest, seeds a
placeholder mailto: subject the operator can edit, and returns only the
public key. The route is cookie-only via requireAdmin(); a Bearer token
cannot reach it. The private key is never returned and never logged — the
audit detail carries a marker only.

The admin Web Push VAPID card gains a Generate keys button that fills the
public key and subject fields on success. When a keypair already exists
the route refuses with 409 and the card confirms before retrying with
force, warning that regenerating invalidates existing browser
subscriptions.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:58:30 +02:00
1 parent 45ccdcb305
commit d0ba4b061b
9 files changed
+423 -1

No files matched your search

+5
View File
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Optional: neuen Private Key eintragen",
"webPushVapidSubject": "VAPID Subject",
"webPushVapidSubjectPlaceholder": "mailto:deine-adresse@example.com",
"webPushVapidGenerate": "Schlüssel erzeugen",
"webPushVapidGenerating": "Wird erzeugt…",
"webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.",
"webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.",
"webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?",
"configured": "Konfiguriert",
"monitoringTestSuccess": "Testevent wurde gesendet",
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
+5
View File
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Optional: enter a new private key",
"webPushVapidSubject": "VAPID subject",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Generate keys",
"webPushVapidGenerating": "Generating…",
"webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.",
"webPushVapidGenerateFailed": "Could not generate VAPID keys.",
"webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?",
"configured": "Configured",
"monitoringTestSuccess": "Test event sent",
"monitoringTestFailed": "Monitoring test failed",
+5
View File
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Opcional: introduce una nueva clave privada",
"webPushVapidSubject": "Subject de VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Generar claves",
"webPushVapidGenerating": "Generando…",
"webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.",
"webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.",
"webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?",
"configured": "Configurado",
"monitoringTestSuccess": "Evento de prueba enviado",
"monitoringTestFailed": "La prueba de monitorización falló",
+5
View File
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Facultatif : saisissez une nouvelle clé privée",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Générer les clés",
"webPushVapidGenerating": "Génération…",
"webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.",
"webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.",
"webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?",
"configured": "Configuré",
"monitoringTestSuccess": "Événement de test envoyé",
"monitoringTestFailed": "Échec du test de surveillance",
+5
View File
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Facoltativo: inserisci una nuova chiave privata",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Genera chiavi",
"webPushVapidGenerating": "Generazione…",
"webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.",
"webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.",
"webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?",
"configured": "Configurato",
"monitoringTestSuccess": "Evento di prova inviato",
"monitoringTestFailed": "Test di monitoraggio non riuscito",
+5
View File
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Opcjonalnie: wprowadź nowy klucz prywatny",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Wygeneruj klucze",
"webPushVapidGenerating": "Generowanie…",
"webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.",
"webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.",
"webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?",
"configured": "Skonfigurowano",
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
"monitoringTestFailed": "Test monitorowania nie powiódł się",
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `enc(${value})`),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateAppSettings: vi.fn(),
}));
vi.mock("web-push", () => ({
generateVAPIDKeys: vi.fn(() => ({
publicKey: "GENERATED_PUBLIC",
privateKey: "GENERATED_PRIVATE",
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
function jsonReq(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/admin/settings/web-push-vapid/generate",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
} as never);
});
describe("POST /api/admin/settings/web-push-vapid/generate", () => {
it("rejects with 401 when no session (admin-gated)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
await expect(POST(jsonReq({}))).rejects.toThrow("Not authenticated");
});
it("rejects with 403 for a non-admin (cookie-only boundary)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(POST(jsonReq({}))).rejects.toThrow("Admin access required");
});
it("generates a keypair and stores the private key encrypted", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await POST(jsonReq({}));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
webPushVapidPublicKey: string;
webPushVapidSubject: string;
webPushVapidConfigured: boolean;
};
};
expect(body.data.webPushVapidPublicKey).toBe("GENERATED_PUBLIC");
expect(body.data.webPushVapidConfigured).toBe(true);
// Default placeholder subject seeded so the keypair is immediately valid.
expect(body.data.webPushVapidSubject).toBe("mailto:admin@example.com");
// Private key encrypted before persistence; never returned in the body.
expect(encrypt).toHaveBeenCalledWith("GENERATED_PRIVATE");
expect(JSON.stringify(body.data)).not.toContain("GENERATED_PRIVATE");
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.where).toEqual({ id: "singleton" });
expect(upsert?.update).toMatchObject({
webPushVapidPublicKey: "GENERATED_PUBLIC",
webPushVapidPrivateKeyEncrypted: "enc(GENERATED_PRIVATE)",
});
});
it("never logs the plaintext private key in the audit detail", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
await POST(jsonReq({}));
expect(auditLog).toHaveBeenCalled();
const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
string,
unknown
>;
expect(JSON.stringify(details)).not.toContain("GENERATED_PRIVATE");
expect(details.webPushVapidPrivateKeyUpdated).toBe(true);
expect(details.replacedExisting).toBe(false);
});
it("refuses with 409 when keys already exist and force is absent", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
webPushVapidPublicKey: "OLD_PUBLIC",
webPushVapidPrivateKeyEncrypted: "OLD_ENC",
webPushVapidSubject: "mailto:old@example.com",
} as never);
const res = await POST(jsonReq({}));
expect(res.status).toBe(409);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("overwrites existing keys when force is true and keeps the subject", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
webPushVapidPublicKey: "OLD_PUBLIC",
webPushVapidPrivateKeyEncrypted: "OLD_ENC",
webPushVapidSubject: "mailto:keep@example.com",
} as never);
const res = await POST(jsonReq({ force: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { webPushVapidSubject: string };
};
// Existing subject preserved when no override is supplied.
expect(body.data.webPushVapidSubject).toBe("mailto:keep@example.com");
const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
string,
unknown
>;
expect(details.replacedExisting).toBe(true);
});
it("rejects an invalid subject override with 422", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await POST(jsonReq({ subject: "not-a-mailto" }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("returns 415 when content-type is not JSON", async () => {
const r = new NextRequest(
"http://localhost/api/admin/settings/web-push-vapid/generate",
{
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
},
);
const res = await POST(r);
expect(res.status).toBe(415);
});
});
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
import { z } from "zod/v4";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const generateSchema = z
.object({
// Overwrite guard — the client must opt in to replacing an existing
// keypair. Regenerating invalidates every browser subscription signed
// against the old public key, so the UI confirms before sending this.
force: z.boolean().optional(),
// Optional subject override. When omitted the existing subject is kept,
// or a neutral placeholder seeded so the keypair is immediately usable.
subject: z
.string()
.refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
message: "subject must be in mailto:address@example.com format",
})
.optional(),
})
.strict();
/**
* POST /api/admin/settings/web-push-vapid/generate
*
* Server-side VAPID keypair generation — the self-hoster DX win. Calls
* `web-push`'s `generateVAPIDKeys()`, encrypts the private key at rest
* (`src/lib/crypto.ts`), and persists the pair onto the AppSettings
* singleton. Returns only the public key + subject; the private key never
* leaves the server and is never logged (the egress redactor covers the
* encrypted blob, and the audit detail carries only a "generated" marker).
*
* Cookie-only via `requireAdmin()` — a Bearer token can never reach it.
*
* Overwrite guard: if a keypair already exists, the call refuses with 409
* unless `force: true` is supplied. Regenerating invalidates existing
* browser subscriptions, so the operator must opt in.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.webPushVapid.generate" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = generateSchema.safeParse(body ?? {});
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const existing = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
webPushVapidPublicKey: true,
webPushVapidPrivateKeyEncrypted: true,
webPushVapidSubject: true,
},
});
const alreadyConfigured = Boolean(
existing?.webPushVapidPublicKey &&
existing?.webPushVapidPrivateKeyEncrypted,
);
if (alreadyConfigured && parsed.data.force !== true) {
// Overwrite guard — surface the existing public key so the UI can warn
// the operator that regenerating invalidates current subscriptions.
return apiError(
"VAPID keys already configured. Regenerating invalidates existing browser subscriptions. Resend with force to replace them.",
409,
);
}
// Lazy import — mirrors the sender so a build without `web-push` still
// type-checks. `generateVAPIDKeys()` returns Base64URL public + private.
const webpush = await import("web-push");
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
// Subject precedence: explicit override → existing subject → placeholder.
// The placeholder keeps the keypair immediately valid; the admin edits
// the real contact address inline in the card afterwards.
const subjectOverride = parsed.data.subject?.trim();
const subject =
(subjectOverride && subjectOverride.length > 0
? subjectOverride
: existing?.webPushVapidSubject) || "mailto:admin@example.com";
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: {
webPushVapidPublicKey: publicKey,
webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
webPushVapidSubject: subject,
},
create: {
id: "singleton",
webPushVapidPublicKey: publicKey,
webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
webPushVapidSubject: subject,
},
});
invalidateAppSettings();
await auditLog("admin.web_push_vapid.generate", {
userId: user.id,
ipAddress: getClientIp(request),
// Never the private key — only a marker that a fresh pair was minted
// and whether it replaced an existing one.
details: {
webPushVapidPrivateKeyUpdated: true,
replacedExisting: alreadyConfigured,
webPushVapidSubject: subject,
},
});
// Public key + subject only; the private key stays server-side.
return apiSuccess({
webPushVapidPublicKey: publicKey,
webPushVapidSubject: subject,
webPushVapidConfigured: true,
});
});
@@ -1,12 +1,16 @@
"use client";
import { useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
ConfiguredBadge,
PasswordInput,
@@ -16,6 +20,7 @@ import {
export function WebPushVapidSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
@@ -26,6 +31,7 @@ export function WebPushVapidSection() {
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
string | null
>(null);
const [generating, setGenerating] = useState(false);
const webPushVapidPublicKeyValue =
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
@@ -52,6 +58,53 @@ export function WebPushVapidSection() {
});
}
async function generateVapidKeys(force: boolean) {
setGenerating(true);
try {
const res = await apiFetchRaw(
"/api/admin/settings/web-push-vapid/generate",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(force ? { force: true } : {}),
},
);
if (res.status === 409) {
// Overwrite guard — existing keys would be replaced. Confirm with
// the operator (regenerating invalidates current subscriptions),
// then retry with force.
if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) {
await generateVapidKeys(true);
}
return;
}
if (!res.ok) {
toast.error(t("admin.webPushVapidGenerateFailed"));
return;
}
const json = (await res.json()) as {
data: { webPushVapidPublicKey: string; webPushVapidSubject: string };
};
// The private key was minted and encrypted server-side; only the
// public key + subject come back. Populate the visible fields and
// leave the private-key input empty (it stays "configured").
setWebPushVapidPublicKeyDraft(json.data.webPushVapidPublicKey);
setWebPushVapidSubjectDraft(json.data.webPushVapidSubject);
setWebPushVapidPrivateKeyDraft("");
await queryClient.invalidateQueries({
queryKey: queryKeys.adminSettings(),
});
toast.success(t("admin.webPushVapidGenerated"));
} catch {
toast.error(t("admin.webPushVapidGenerateFailed"));
} finally {
setGenerating(false);
}
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
@@ -122,6 +175,21 @@ export function WebPushVapidSection() {
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void generateVapidKeys(false)}
disabled={generating || updateSettings.isPending}
>
{generating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-3.5 w-3.5" />
)}
{generating
? t("admin.webPushVapidGenerating")
: t("admin.webPushVapidGenerate")}
</Button>
<Button
size="sm"
onClick={saveWebPushVapidSettings}