Revert "security: enforce authenticated CRM contact ownership"

This reverts commit e13700ab65.
This commit is contained in:
2026-11-06 06:42:16 +00:00
parent e13700ab65
commit e1f8fb5c96
7 changed files with 126 additions and 517 deletions
-42
View File
@@ -1,42 +0,0 @@
import { azureHeadersPaged } from "../../actions/core/headers";
import { escapeODataString } from "../../actions/core/guards";
import { consoleLogger } from "../../actions/core/logger";
import { relayGetData } from "../../pages/api/middleware/relayForwarding";
import { RELAY_POLICY_STRICT_LOGIN } from "../../pages/api/middleware/relayPolicyPresets";
const WELSH_LANGUAGE_CODE = 846040000;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const resolveCrmLocale = async (email) => {
if (typeof email !== "string") return null;
const normalizedEmail = email.trim().toLowerCase();
if (!EMAIL_PATTERN.test(normalizedEmail)) return null;
const queryUrl =
"contacts?$filter=emailaddress1 eq '" +
escapeODataString(normalizedEmail) +
"' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage";
try {
const { data } = await relayGetData({
queryUrl,
requestOptionsBuilder: (accessToken) =>
azureHeadersPaged(accessToken),
relayPolicy: RELAY_POLICY_STRICT_LOGIN
});
const preferredLanguage = data?.value?.[0]?.pinswg_preferredlanguage;
if (preferredLanguage === WELSH_LANGUAGE_CODE) return "cy";
if (preferredLanguage != null) return "en";
return null;
} catch (error) {
consoleLogger({
name: "ResolveCrmLocale",
message: "CRM locale lookup failed"
});
return null;
}
};
+26 -1
View File
@@ -14,8 +14,8 @@ import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import NextAuth from "next-auth";
import EmailProvider from "../../../lib/auth/email-provider";
import { resolveCrmLocale } from "../../../lib/auth/resolveCrmLocale";
import { consoleLogger, redactSensitive } from "../../../actions/core/logger";
import { getPortalLogin } from "../../../actions/services/accountService";
const prisma = new PrismaClient();
@@ -53,6 +53,8 @@ const createVerificationTokenSynchronizationAdapter = (
};
};
const WELSH_LANGUAGE_CODE = 846040000;
const authDiagnostic = (event, metadata = {}) => {
try {
const safeMeta = JSON.parse(JSON.stringify(metadata));
@@ -82,6 +84,29 @@ const appendParamsAndPathToNewUrl = (fromUrl, toUrl) => {
}
};
const resolveCrmLocale = async (email) => {
if (!email) return null;
try {
const portalUserObj = await getPortalLogin(email);
const preferredLanguage =
portalUserObj?.value?.[0]?.pinswg_preferredlanguage;
if (preferredLanguage === WELSH_LANGUAGE_CODE) {
return "cy";
}
if (preferredLanguage != null) {
return "en";
}
return null;
} catch (error) {
consoleLogger(error);
return null;
}
};
const resolveEffectiveLocale = async (req, email) => {
const crmLocale = await resolveCrmLocale(email);
if (crmLocale) {
+30 -3
View File
@@ -1,3 +1,8 @@
import { getPortalLogin } from "../../../actions/services/accountService";
import { consoleLogger } from "../../../actions/core/logger";
const WELSH_LANGUAGE_CODE = 846040000;
const resolveRequestLocale = (req) =>
req?.query?.locale ||
req?.body?.locale ||
@@ -9,8 +14,30 @@ export default async function handler(req, res) {
return res.status(405).json({ message: "Method not allowed" });
}
const requestedLocale = resolveRequestLocale(req);
const locale = requestedLocale === "cy" ? "cy" : "en";
const email = String(req.body?.email || "")
.trim()
.toLowerCase();
const sessionLocale = resolveRequestLocale(req);
return res.status(200).json({ locale });
if (!email) {
return res.status(200).json({ locale: sessionLocale });
}
try {
const portalUserObj = await getPortalLogin(email);
const preferredLanguage =
portalUserObj?.value?.[0]?.pinswg_preferredlanguage;
const locale =
preferredLanguage === WELSH_LANGUAGE_CODE
? "cy"
: preferredLanguage != null
? "en"
: sessionLocale;
return res.status(200).json({ locale });
} catch (error) {
consoleLogger(error);
return res.status(200).json({ locale: sessionLocale });
}
}
+4 -45
View File
@@ -17,61 +17,20 @@
*/
import { azureHeadersPaged } from "../../../actions/core/headers";
import { escapeODataString } from "../../../actions/core/guards";
import { hashAPIPath } from "../../../actions/core/hash";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_STRICT_LOGIN } from "../middleware/relayPolicyPresets";
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
export default async function ApiProxy(req, res) {
if (req.method !== "GET") {
return respondError(res, {
status: 405,
code: "METHOD_NOT_ALLOWED",
message: "Only GET is supported"
});
}
const session = await getServerSession(req, res, authOptions(req, res));
const authenticatedEmail = session?.user?.email;
if (
typeof authenticatedEmail !== "string" ||
!EMAIL_PATTERN.test(authenticatedEmail.trim())
) {
return respondError(res, {
status: 401,
code: "UNAUTHENTICATED",
message: "Authentication required"
});
}
const emailAddress = req.query.emailAddress;
const checkHash = req.query.hash;
if (
typeof emailAddress !== "string" ||
!EMAIL_PATTERN.test(emailAddress.trim())
) {
if (typeof emailAddress !== "string" || emailAddress.trim().length === 0) {
return respondError(res, {
status: 400,
code: "INVALID_EMAIL_ADDRESS",
message: "emailAddress must be a valid email address"
});
}
const normalizedAuthenticatedEmail = authenticatedEmail.trim().toLowerCase();
const normalizedRequestedEmail = emailAddress.trim().toLowerCase();
if (normalizedRequestedEmail !== normalizedAuthenticatedEmail) {
return respondError(res, {
status: 403,
code: "FORBIDDEN",
message: "Request not permitted"
code: "EMAIL_ADDRESS_REQUIRED",
message: "emailAddress is required"
});
}
@@ -104,7 +63,7 @@ export default async function ApiProxy(req, res) {
const queryUrl =
"contacts?$filter=emailaddress1 eq '" +
escapeODataString(normalizedAuthenticatedEmail) +
emailAddress +
"' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname";
const relayPolicy = RELAY_POLICY_STRICT_LOGIN;
+64 -179
View File
@@ -323,157 +323,64 @@ test("getlogin passes strict relayPolicy overrides to relayGet", async () => {
});
});
const createPortalLoginFixture = (overrides = {}) => {
const calls = {
session: [],
hash: [],
relay: []
};
const session = Object.prototype.hasOwnProperty.call(overrides, "session")
? overrides.session
: { user: { email: "user@test.local" } };
test("getportallogin returns HASH_REQUIRED when hash missing", async () => {
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
getServerSession: async (...args) => {
calls.session.push(args);
return session;
},
authOptions: (req, res) => ({ req, res }),
relayGet: async (options) => {
calls.relay.push(options);
if (overrides.relayGet) return overrides.relayGet(options);
return respondSuccessMock(options.res, { value: [] });
},
hashAPIPath: (input) => {
calls.hash.push(input);
if (overrides.hashAPIPath) return overrides.hashAPIPath(input);
return "&hash=expected";
},
escapeODataString: (value) => value.trim().replace(/'/g, "''"),
azureHeadersPaged: (accessToken) => ({ accessToken })
});
return { mod, calls };
};
const invokePortalLogin = async (
fixture,
query = { emailAddress: "user@test.local", hash: "expected" },
method = "GET"
) => {
const req = { method, query };
const res = createRes();
await fixture.mod.default(req, res);
return { req, res };
};
test("getportallogin rejects unsupported methods before authentication", async () => {
const fixture = createPortalLoginFixture();
const { res } = await invokePortalLogin(fixture, undefined, "POST");
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
assert.strictEqual(fixture.calls.session.length, 0);
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin rejects missing session", async () => {
const fixture = createPortalLoginFixture({ session: null });
const { req, res } = await invokePortalLogin(fixture);
assert.strictEqual(res.state.statusCode, 401);
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
assert.strictEqual(fixture.calls.session[0][0], req);
assert.strictEqual(fixture.calls.session[0][1], res);
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin rejects session without email", async () => {
const fixture = createPortalLoginFixture({
session: { user: { id: "application-user" } }
});
const { res } = await invokePortalLogin(fixture);
assert.strictEqual(res.state.statusCode, 401);
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin rejects malformed requested email", async () => {
const fixture = createPortalLoginFixture();
const { res } = await invokePortalLogin(fixture, {
emailAddress: "not-an-email",
hash: "expected"
});
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_EMAIL_ADDRESS");
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin rejects another user's email before CRM access", async () => {
const fixture = createPortalLoginFixture();
const { res } = await invokePortalLogin(fixture, {
emailAddress: "other@test.local",
hash: "expected"
});
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assert.strictEqual(fixture.calls.hash.length, 0);
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin valid HMAC for another email does not bypass ownership", async () => {
const fixture = createPortalLoginFixture({
relayGet: async () => ({}),
hashAPIPath: (input) =>
input.includes("other%40test.local") ||
input.includes("other@test.local")
? "&hash=valid-other-user-hash"
: "&hash=unexpected"
});
const { res } = await invokePortalLogin(fixture, {
emailAddress: "other@test.local",
hash: "valid-other-user-hash"
input && input.startsWith("/api/endpoint/getportallogin_api")
? "&hash=expected"
: "&hash=relay",
azureHeadersPaged: () => ({})
});
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin returns HASH_REQUIRED when hash missing", async () => {
const fixture = createPortalLoginFixture();
const { res } = await invokePortalLogin(fixture, {
emailAddress: "user@test.local"
});
const req = { query: { emailAddress: "user@test.local" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin returns INVALID_HASH when hash mismatch", async () => {
const fixture = createPortalLoginFixture();
const { res } = await invokePortalLogin(fixture, {
emailAddress: "user@test.local",
hash: "wrong"
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async () => ({}),
hashAPIPath: (input) =>
input && input.startsWith("/api/endpoint/getportallogin_api")
? "&hash=expected"
: "&hash=relay",
azureHeadersPaged: () => ({})
});
const req = { query: { emailAddress: "user@test.local", hash: "wrong" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assert.strictEqual(fixture.calls.relay.length, 0);
});
test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => {
const fixture = createPortalLoginFixture({
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ res, errorResponse }) =>
respondErrorMock(res, errorResponse)
respondErrorMock(res, errorResponse),
hashAPIPath: (input) =>
input && input.startsWith("/api/endpoint/getportallogin_api")
? "&hash=expected"
: "&hash=relay",
azureHeadersPaged: () => ({})
});
const { res } = await invokePortalLogin(fixture);
const req = {
query: { emailAddress: "user@test.local", hash: "expected" }
};
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
@@ -484,13 +391,26 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () =>
test("getportallogin passes strict relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
const fixture = createPortalLoginFixture({
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
}
},
hashAPIPath: (input) =>
input && input.startsWith("/api/endpoint/getportallogin_api")
? "&hash=expected"
: "&hash=relay",
azureHeadersPaged: () => ({})
});
const { res } = await invokePortalLogin(fixture);
const req = {
query: { emailAddress: "user@test.local", hash: "expected" }
};
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
@@ -503,65 +423,30 @@ test("getportallogin passes strict relayPolicy overrides to relayGet", async ()
});
test("getportallogin accepts encoded email hash variant", async () => {
const fixture = createPortalLoginFixture({
session: { user: { email: "user+test@local" } },
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ res }) =>
respondSuccessMock(res, { value: [{ contactid: "c1" }] }),
hashAPIPath: (input) =>
input && input.includes("emailAddress=user%2Btest%40local")
? "&hash=expected"
: "&hash=other"
});
const { res } = await invokePortalLogin(fixture, {
emailAddress: "user+test@local",
hash: "expected"
: "&hash=other",
azureHeadersPaged: () => ({})
});
const req = {
query: { emailAddress: "user+test@local", hash: "expected" }
};
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
value: [{ contactid: "c1" }]
});
});
test("getportallogin uses normalized session email and preserves CRM response", async () => {
let capturedQueryUrl;
const crmResponse = {
value: [
{
pinswg_preferredlanguage: 846040000,
emailaddress1: "user.o'neill@test.local",
contactid: "contact-1",
yomifullname: "O'Neill, User",
firstname: "User",
lastname: "O'Neill"
}
]
};
const fixture = createPortalLoginFixture({
session: { user: { email: " User.O'Neill@Test.Local " } },
relayGet: async ({ queryUrl, res }) => {
capturedQueryUrl = queryUrl;
return respondSuccessMock(res, crmResponse);
}
});
const { res } = await invokePortalLogin(fixture, {
emailAddress: "user.o'neill@test.local",
hash: "expected"
});
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
crmResponse
);
assert.match(capturedQueryUrl, /emailaddress1 eq 'user\.o''neill@test\.local'/);
assert.match(capturedQueryUrl, /statuscode eq 1/);
assert.match(
capturedQueryUrl,
/\$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname$/
);
});
test("getnewappeals catch path returns ADMIN_NEW_APPEALS_FETCH_FAILED", async () => {
const mod = loadModule("pages/api/admin/getnewappeals_api.js", {
respondError: respondErrorMock,
+2 -5
View File
@@ -6,10 +6,7 @@ const vm = require("vm");
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
const loadAuthInternals = ({
adapterFactory = () => ({}),
resolveCrmLocale = async () => null
} = {}) => {
const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => {
const filePath = path.join(
__dirname,
"..",
@@ -59,7 +56,7 @@ const loadAuthInternals = ({
},
NextAuth: () => ({}),
EmailProvider: () => ({}),
resolveCrmLocale,
getPortalLogin: async () => ({ value: [] }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
@@ -1,242 +0,0 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const rootDir = path.resolve(__dirname, "..", "..");
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
const loadModule = (relativePath, injected = {}) => {
const filePath = path.join(rootDir, relativePath);
let source = fs.readFileSync(filePath, "utf8");
const exportNames = Array.from(
source.matchAll(/export const\s+(\w+)\s*=/g)
).map((match) => match[1]);
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
source = source.replace(/export const\s+/g, "const ");
source = source.replace(
/export default async function\s+(\w+)\s*\(/g,
"async function $1("
);
source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`;
source +=
'\nif (typeof handler !== "undefined") module.exports.default = handler;\n';
const context = {
module: { exports: {} },
exports: {},
require,
process,
console: { log: () => {}, info: () => {}, warn: () => {}, error: () => {} },
...injected
};
vm.runInNewContext(source, context, { filename: filePath });
return context.module.exports;
};
const normalize = (value) => JSON.parse(JSON.stringify(value));
const createRes = () => {
const state = { statusCode: null, jsonBody: undefined };
return {
state,
status(code) {
state.statusCode = code;
return this;
},
json(body) {
state.jsonBody = body;
return body;
}
};
};
const loadLocaleResolver = ({ relayGetData }) =>
loadModule("lib/auth/resolveCrmLocale.js", {
azureHeadersPaged: (accessToken) => ({ accessToken }),
escapeODataString: (value) => value.trim().replace(/'/g, "''"),
consoleLogger: () => {},
relayGetData,
RELAY_POLICY_STRICT_LOGIN: {
method: "GET",
timeoutMs: 5000,
maxRetries: 0,
retryBaseDelayMs: 0,
retryMaxDelayMs: 0
}
});
test("pre-auth CRM locale lookup returns Welsh and selects only language", async () => {
let capturedRequest;
const mod = loadLocaleResolver({
relayGetData: async (request) => {
capturedRequest = request;
return {
data: {
value: [
{
pinswg_preferredlanguage: 846040000,
contactid: "must-not-be-returned",
firstname: "Must not be returned"
}
]
}
};
}
});
const result = await mod.resolveCrmLocale(" Person@Example.com ");
const selectClause = new URLSearchParams(
capturedRequest.queryUrl.split("?")[1]
).get("$select");
assert.strictEqual(result, "cy");
assert.match(capturedRequest.queryUrl, /\$filter=emailaddress1 eq /);
assert.match(capturedRequest.queryUrl, /statuscode eq 1/);
assert.strictEqual(selectClause, "pinswg_preferredlanguage");
assert.ok(result === "en" || result === "cy" || result === null);
for (const field of [
"contactid",
"emailaddress1",
"firstname",
"lastname",
"yomifullname"
]) {
assert.doesNotMatch(selectClause, new RegExp(field));
assert.doesNotMatch(result, new RegExp(field));
}
});
test("pre-auth CRM locale lookup returns English for a non-Welsh value", async () => {
const mod = loadLocaleResolver({
relayGetData: async () => ({
data: { value: [{ pinswg_preferredlanguage: 846040001 }] }
})
});
assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), "en");
});
test("pre-auth CRM locale lookup returns null for no contact", async () => {
const mod = loadLocaleResolver({
relayGetData: async () => ({ data: { value: [] } })
});
assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), null);
});
test("pre-auth CRM locale lookup preserves first-contact duplicate semantics", async () => {
const mod = loadLocaleResolver({
relayGetData: async () => ({
data: {
value: [
{ pinswg_preferredlanguage: 846040001 },
{ pinswg_preferredlanguage: 846040000 }
]
}
})
});
assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), "en");
});
test("pre-auth CRM locale lookup rejects malformed email without CRM access", async () => {
let relayCalls = 0;
const mod = loadLocaleResolver({
relayGetData: async () => {
relayCalls += 1;
return { data: { value: [] } };
}
});
assert.strictEqual(await mod.resolveCrmLocale("not-an-email"), null);
assert.strictEqual(relayCalls, 0);
});
test("pre-auth CRM locale lookup fails closed without leaking CRM details", async () => {
const mod = loadLocaleResolver({
relayGetData: async () => {
throw new Error("CRM secret response details");
}
});
assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), null);
});
test("pre-auth CRM locale lookup escapes OData apostrophes", async () => {
let queryUrl;
const mod = loadLocaleResolver({
relayGetData: async (request) => {
queryUrl = request.queryUrl;
return { data: { value: [] } };
}
});
await mod.resolveCrmLocale("o'connor@example.com");
assert.match(queryUrl, /o''connor@example\.com/);
});
test("public sign-in locale route preserves response without CRM lookup", async () => {
const source = fs.readFileSync(
path.join(rootDir, "pages/api/auth/resolve-locale.js"),
"utf8"
);
const mod = loadModule("pages/api/auth/resolve-locale.js");
const res = createRes();
await mod.default(
{
method: "POST",
body: { email: "other@example.com", locale: "cy" },
query: {},
cookies: {}
},
res
);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(normalize(res.state.jsonBody), { locale: "cy" });
assert.doesNotMatch(source, /getPortalLogin|resolveCrmLocale|relayGetData/);
});
test("both pre-auth callers no longer depend on getPortalLogin", () => {
const nextAuthSource = fs.readFileSync(
path.join(rootDir, "pages/api/auth/[...nextauth].js"),
"utf8"
);
const routeSource = fs.readFileSync(
path.join(rootDir, "pages/api/auth/resolve-locale.js"),
"utf8"
);
assert.doesNotMatch(nextAuthSource, /getPortalLogin/);
assert.doesNotMatch(routeSource, /getPortalLogin/);
assert.match(nextAuthSource, /resolveCrmLocale/);
});
const run = async () => {
let passed = 0;
for (const currentTest of tests) {
await currentTest.fn();
passed += 1;
}
console.log(
`Phase 22 pre-auth locale tests passed (${passed}/${tests.length}).`
);
};
if (require.main === module) {
run().catch((error) => {
console.error(error);
process.exit(1);
});
}
module.exports = run;