From 569faa77e526814cfc36f5cbf69029712d29fd42 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 4 Nov 2026 11:26:01 +0000 Subject: [PATCH] fix(security): isolate NextAuth email provider from Nodemailer --- lib/auth/email-provider.js | 31 +++++ package-lock.json | 3 +- package.json | 3 +- pages/api/auth/[...nextauth].js | 2 +- .../phase22/email-provider-contract.test.cjs | 113 ++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 lib/auth/email-provider.js create mode 100644 tests/phase22/email-provider-contract.test.cjs diff --git a/lib/auth/email-provider.js b/lib/auth/email-provider.js new file mode 100644 index 00000000..d1bdaceb --- /dev/null +++ b/lib/auth/email-provider.js @@ -0,0 +1,31 @@ +/** + * NextAuth email provider configuration for PEDW. + * + * PEDW supplies its own sendVerificationRequest implementation using + * GOV.UK Notify, so the default Nodemailer SMTP sender is unnecessary. + * + * Preserve the NextAuth v4 email provider configuration contract. + */ +const EmailProvider = (options = {}) => ({ + id: "email", + type: "email", + name: "Email", + server: { + host: "localhost", + port: 25, + auth: { + user: "", + pass: "" + } + }, + from: "NextAuth ", + maxAge: 24 * 60 * 60, + async sendVerificationRequest() { + throw new Error( + "PEDW requires a custom GOV.UK Notify verification sender" + ); + }, + options +}); + +export default EmailProvider; diff --git a/package-lock.json b/package-lock.json index 2d0cf56c..e49b7321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,6 @@ "next-redux-wrapper": "^7.0.5", "next-translate": "^2.6.2", "next-translate-plugin": "^2.6.2", - "nodemailer": "7.0.13", "notifications-node-client": "^7.0.6", "openapi-types": "^12.1.3", "ospoint": "^0.2.1", @@ -7323,6 +7322,8 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", + "optional": true, + "peer": true, "engines": { "node": ">=6.0.0" } diff --git a/package.json b/package.json index 6f78b955..ed9de034 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "next-redux-wrapper": "^7.0.5", "next-translate": "^2.6.2", "next-translate-plugin": "^2.6.2", - "nodemailer": "7.0.13", "notifications-node-client": "^7.0.6", "openapi-types": "^12.1.3", "ospoint": "^0.2.1", @@ -110,4 +109,4 @@ "eslint-plugin-jsdoc": "^48.2.3", "prisma": "^5.0.0" } -} \ No newline at end of file +} diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 7662326f..ac88da54 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -13,7 +13,7 @@ import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaClient } from "@prisma/client"; import NextAuth from "next-auth"; -import EmailProvider from "next-auth/providers/email"; +import EmailProvider from "../../../lib/auth/email-provider"; import { consoleLogger, redactSensitive } from "../../../actions/core/logger"; import { getPortalLogin } from "../../../actions/services/accountService"; diff --git a/tests/phase22/email-provider-contract.test.cjs b/tests/phase22/email-provider-contract.test.cjs new file mode 100644 index 00000000..b8d000b2 --- /dev/null +++ b/tests/phase22/email-provider-contract.test.cjs @@ -0,0 +1,113 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const root = path.resolve(__dirname, "../.."); + +const source = fs.readFileSync( + path.join(root, "lib/auth/email-provider.js"), + "utf8" +); + +const EmailProvider = vm.runInNewContext( + source.replace("export default EmailProvider;", "") + "\nEmailProvider;", + {} +); + +const OriginalEmailProvider = require("next-auth/providers/email").default; + +const customSender = async () => {}; + +const options = { + maxAge: 2 * 60 * 60, + sendVerificationRequest: customSender +}; + +const normalize = (provider) => { + const { options: userOptions, ...defaults } = provider; + + return { + ...defaults, + ...userOptions + }; +}; + +test("local provider preserves NextAuth email provider defaults", () => { + const original = OriginalEmailProvider(options); + const replacement = EmailProvider(options); + + for (const property of ["id", "type", "name", "server", "from", "maxAge"]) { + assert.deepEqual( + JSON.parse(JSON.stringify(replacement[property])), + JSON.parse(JSON.stringify(original[property])), + `Mismatch in ${property}` + ); + } +}); + +test("local provider preserves the user options contract", () => { + const provider = EmailProvider(options); + + assert.equal(provider.options.maxAge, 7200); + assert.equal(provider.options.sendVerificationRequest, customSender); +}); + +test("normalized provider retains two-hour token expiry", () => { + const original = normalize(OriginalEmailProvider(options)); + const replacement = normalize(EmailProvider(options)); + + assert.equal(original.maxAge, 7200); + assert.equal(replacement.maxAge, original.maxAge); +}); + +test("normalized provider uses the custom verification sender", () => { + const provider = normalize(EmailProvider(options)); + + assert.equal(provider.sendVerificationRequest, customSender); +}); + +test("provider retains NextAuth email identity", () => { + const provider = normalize(EmailProvider(options)); + + assert.equal(provider.id, "email"); + assert.equal(provider.type, "email"); + assert.equal(provider.name, "Email"); +}); + +test("default sender fails explicitly without PEDW override", async () => { + const provider = EmailProvider(); + + await assert.rejects( + provider.sendVerificationRequest(), + /requires a custom GOV\.UK Notify verification sender/ + ); +}); + +test("local provider does not import Nodemailer", () => { + assert.doesNotMatch( + source, + /(?:require\s*\(|from\s+|import\s*\()\s*["']nodemailer/ + ); +}); + +test("authentication handler imports the local provider", () => { + const authSource = fs.readFileSync( + path.join(root, "pages/api/auth/[...nextauth].js"), + "utf8" + ); + + assert.match( + authSource, + /import EmailProvider from ["']\.\.\/\.\.\/\.\.\/lib\/auth\/email-provider["']/ + ); + + assert.doesNotMatch(authSource, /from ["']next-auth\/providers\/email["']/); + + assert.match(authSource, /EmailProvider\s*\(\s*\{/); + + assert.match(authSource, /async sendVerificationRequest\s*\(/); + + assert.match(authSource, /notifyClient\.sendEmail\s*\(/); +});