From 6094874851607511b5d2cacd12f40ddd122e5cf5 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 13 Mar 2026 14:01:18 +0000 Subject: [PATCH] Route portal login hashing through authenticated signer --- actions/services/accountDirectService.js | 22 +++++++++++++++- pages/api/endpoint/gethash_api.js | 1 + tests/phase14/service-behaviour.test.cjs | 20 ++++++++++++++ tests/phase7/service-behaviour.test.cjs | 33 +++++++++++++++--------- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/actions/services/accountDirectService.js b/actions/services/accountDirectService.js index 5f719fd2..5a236450 100644 --- a/actions/services/accountDirectService.js +++ b/actions/services/accountDirectService.js @@ -3,6 +3,26 @@ import { BASE_URL } from "../core/env"; import { consoleLogger } from "../core/logger"; import { hashAPIPath } from "../core/hash"; +const buildHashedQueryUrl = async (queryUrl) => { + try { + const signRes = await axios.get( + "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl) + ); + + if (!signRes?.data?.hash) { + throw new Error("Hash signature unavailable"); + } + + return queryUrl + signRes.data.hash; + } catch (error) { + if (typeof window === "undefined" && process.env.HASHKEY) { + return queryUrl + hashAPIPath(queryUrl); + } + + throw error; + } +}; + export const getPersonalAccount = (contactid) => { return axios .get( @@ -105,7 +125,7 @@ export const getPortalLogin = async (emailAddress) => { "/api/endpoint/getportallogin_api?emailAddress=" + emailAddress; return axios - .get(BASE_URL + queryUrl + hashAPIPath(queryUrl)) + .get(BASE_URL + (await buildHashedQueryUrl(queryUrl))) .then((res) => res.data) .catch((error) => { consoleLogger(error); diff --git a/pages/api/endpoint/gethash_api.js b/pages/api/endpoint/gethash_api.js index 3555f75a..5927cf87 100644 --- a/pages/api/endpoint/gethash_api.js +++ b/pages/api/endpoint/gethash_api.js @@ -13,6 +13,7 @@ ApiProxy.get(async (req, res) => { const queryPath = req.query.path; const allowedPrefix = [ + "/api/endpoint/getportallogin_api", "/api/file/upload", "/api/file/uploadsinglefile", "/api/file/createrepcompletemessage_api", diff --git a/tests/phase14/service-behaviour.test.cjs b/tests/phase14/service-behaviour.test.cjs index 004b7735..86bb0714 100644 --- a/tests/phase14/service-behaviour.test.cjs +++ b/tests/phase14/service-behaviour.test.cjs @@ -112,6 +112,26 @@ test("gethash_api returns hash for valid api path", async () => { }); }); +test("gethash_api returns hash for allow-listed getportallogin path", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "&hash=login", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { + query: { path: "/api/endpoint/getportallogin_api?emailAddress=a@b.com" } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + hash: "&hash=login" + }); +}); + test("gethash_api rejects unauthenticated requests with 401", async () => { const mod = loadModule("pages/api/endpoint/gethash_api.js", { hashAPIPath: () => "&hash=expected", diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs index 381cc31f..00da59c0 100644 --- a/tests/phase7/service-behaviour.test.cjs +++ b/tests/phase7/service-behaviour.test.cjs @@ -208,31 +208,34 @@ test("portal/deleteMyRepresentations logs and returns undefined on failure", asy test("account/getPortalLogin appends hash and returns res.data", async () => { const axios = createAxiosMock(); const logger = createLoggerMock(); - const hashCalls = []; - const hashAPIPath = (queryPath) => { - hashCalls.push(queryPath); - return "&hash=login123"; - }; + const signCalls = []; - axios.getHandler = async () => ({ data: { value: [{ id: "user-1" }] } }); + axios.getHandler = async (url) => { + if (url.startsWith("/api/endpoint/gethash_api?path=")) { + signCalls.push(url); + return { data: { hash: "&hash=login123" } }; + } + + return { data: { value: [{ id: "user-1" }] } }; + }; const account = loadServiceModule("accountDirectService.js", { axios, BASE_URL: "http://example.local", consoleLogger: logger.consoleLogger, - hashAPIPath + hashAPIPath: () => "&hash=fallback" }); const result = await account.getPortalLogin("person@example.com"); assert.deepStrictEqual(normalize(result), { value: [{ id: "user-1" }] }); - assert.strictEqual(hashCalls.length, 1); + assert.strictEqual(signCalls.length, 1); assert.strictEqual( - hashCalls[0], - "/api/endpoint/getportallogin_api?emailAddress=person@example.com" + signCalls[0], + "/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fgetportallogin_api%3FemailAddress%3Dperson%40example.com" ); assert.strictEqual( - axios.calls[0].url, + axios.calls[1].url, "http://example.local/api/endpoint/getportallogin_api?emailAddress=person@example.com&hash=login123" ); }); @@ -242,7 +245,13 @@ test("account/getPortalLogin returns JSON stringified error on failure", async ( const logger = createLoggerMock(); const error = createAxiosError(500, "Broken"); - axios.getHandler = async () => Promise.reject(error); + axios.getHandler = async (url) => { + if (url.startsWith("/api/endpoint/gethash_api?path=")) { + return { data: { hash: "&hash=err" } }; + } + + return Promise.reject(error); + }; const account = loadServiceModule("accountDirectService.js", { axios,