security: enforce authenticated CRM contact ownership
This commit is contained in:
@@ -323,64 +323,157 @@ test("getlogin passes strict relayPolicy overrides to relayGet", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("getportallogin returns HASH_REQUIRED when hash missing", async () => {
|
||||
const createPortalLoginFixture = (overrides = {}) => {
|
||||
const calls = {
|
||||
session: [],
|
||||
hash: [],
|
||||
relay: []
|
||||
};
|
||||
const session = Object.prototype.hasOwnProperty.call(overrides, "session")
|
||||
? overrides.session
|
||||
: { user: { email: "user@test.local" } };
|
||||
|
||||
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: () => ({})
|
||||
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 })
|
||||
});
|
||||
|
||||
const req = { query: { emailAddress: "user@test.local" } };
|
||||
return { mod, calls };
|
||||
};
|
||||
|
||||
const invokePortalLogin = async (
|
||||
fixture,
|
||||
query = { emailAddress: "user@test.local", hash: "expected" },
|
||||
method = "GET"
|
||||
) => {
|
||||
const req = { method, query };
|
||||
const res = createRes();
|
||||
await mod.default(req, res);
|
||||
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({
|
||||
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"
|
||||
});
|
||||
|
||||
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"
|
||||
});
|
||||
|
||||
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 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 fixture = createPortalLoginFixture();
|
||||
const { res } = await invokePortalLogin(fixture, {
|
||||
emailAddress: "user@test.local",
|
||||
hash: "wrong"
|
||||
});
|
||||
|
||||
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 mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
const fixture = createPortalLoginFixture({
|
||||
relayGet: async ({ res, errorResponse }) =>
|
||||
respondErrorMock(res, errorResponse),
|
||||
hashAPIPath: (input) =>
|
||||
input && input.startsWith("/api/endpoint/getportallogin_api")
|
||||
? "&hash=expected"
|
||||
: "&hash=relay",
|
||||
azureHeadersPaged: () => ({})
|
||||
respondErrorMock(res, errorResponse)
|
||||
});
|
||||
|
||||
const req = {
|
||||
query: { emailAddress: "user@test.local", hash: "expected" }
|
||||
};
|
||||
const res = createRes();
|
||||
await mod.default(req, res);
|
||||
const { res } = await invokePortalLogin(fixture);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(
|
||||
@@ -391,26 +484,13 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () =>
|
||||
|
||||
test("getportallogin passes strict relayPolicy overrides to relayGet", async () => {
|
||||
let capturedRelayPolicy = null;
|
||||
|
||||
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
const fixture = createPortalLoginFixture({
|
||||
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 req = {
|
||||
query: { emailAddress: "user@test.local", hash: "expected" }
|
||||
};
|
||||
const res = createRes();
|
||||
await mod.default(req, res);
|
||||
const { res } = await invokePortalLogin(fixture);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
|
||||
@@ -423,23 +503,19 @@ test("getportallogin passes strict relayPolicy overrides to relayGet", async ()
|
||||
});
|
||||
|
||||
test("getportallogin accepts encoded email hash variant", async () => {
|
||||
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
const fixture = createPortalLoginFixture({
|
||||
session: { user: { email: "user+test@local" } },
|
||||
relayGet: async ({ res }) =>
|
||||
respondSuccessMock(res, { value: [{ contactid: "c1" }] }),
|
||||
hashAPIPath: (input) =>
|
||||
input && input.includes("emailAddress=user%2Btest%40local")
|
||||
? "&hash=expected"
|
||||
: "&hash=other",
|
||||
azureHeadersPaged: () => ({})
|
||||
: "&hash=other"
|
||||
});
|
||||
const { res } = await invokePortalLogin(fixture, {
|
||||
emailAddress: "user+test@local",
|
||||
hash: "expected"
|
||||
});
|
||||
|
||||
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)), {
|
||||
@@ -447,6 +523,45 @@ test("getportallogin accepts encoded email hash variant", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -6,7 +6,10 @@ const vm = require("vm");
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push({ name, fn });
|
||||
|
||||
const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => {
|
||||
const loadAuthInternals = ({
|
||||
adapterFactory = () => ({}),
|
||||
resolveCrmLocale = async () => null
|
||||
} = {}) => {
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
@@ -56,7 +59,7 @@ const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => {
|
||||
},
|
||||
NextAuth: () => ({}),
|
||||
EmailProvider: () => ({}),
|
||||
getPortalLogin: async () => ({ value: [] }),
|
||||
resolveCrmLocale,
|
||||
redactSensitive: (value) => value,
|
||||
consoleLogger: () => {},
|
||||
console: {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user