fix(security): isolate NextAuth email provider from Nodemailer

This commit is contained in:
2026-11-04 11:26:01 +00:00
parent d9f0c0797d
commit 569faa77e5
5 changed files with 148 additions and 4 deletions
+31
View File
@@ -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 <no-reply@example.com>",
maxAge: 24 * 60 * 60,
async sendVerificationRequest() {
throw new Error(
"PEDW requires a custom GOV.UK Notify verification sender"
);
},
options
});
export default EmailProvider;
+2 -1
View File
@@ -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"
}
+1 -2
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -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";
@@ -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*\(/);
});