From e9ffc8ca8886b01c5cf3a87e463c0c2ed0b8ea8f Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:19:27 +0100 Subject: [PATCH 1/5] TEMPCOMMIT - Prevent getSession from returning a token if signOut was recently called Precommit hooks bypassed; this is a work in progress commit WIP commit: contains * A Server Action to set the flag cookie * Calling the server action from the user-logout method * cherck the flag in getToken Issue under investigation 1. getToken will return null, but the MyVaccines app then currently tries to redirect the user to NHS App because they are not authenticated, meaning the user will never see the logout / session timeout screens. (at present this throws a cors error in the browser and the user never actually gets to NHS app but this is a quirk and the underlying issue still needs fixing; the user shouldnt be redirected at all for this part of the journey. The network tab shows the cors error and an error would be logged in our service logs). --- package-lock.json | 21 +---- src/utils/auth/callbacks/get-token.test.ts | 91 +++++++++++++++++++++ src/utils/auth/callbacks/get-token.ts | 14 +++- src/utils/auth/inactivity-timer.ts | 4 +- src/utils/auth/setSignOutFlagCookie.test.ts | 41 ++++++++++ src/utils/auth/setSignOutFlagCookie.ts | 23 ++++++ src/utils/auth/user-logout.test.ts | 9 ++ src/utils/auth/user-logout.ts | 2 + src/utils/constants.ts | 1 + 9 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 src/utils/auth/setSignOutFlagCookie.test.ts create mode 100644 src/utils/auth/setSignOutFlagCookie.ts diff --git a/package-lock.json b/package-lock.json index f54a9e104..2e3ee3df9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5342,7 +5342,6 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5382,7 +5381,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5403,7 +5401,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5424,7 +5421,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5445,7 +5441,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5466,7 +5461,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5487,7 +5481,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5508,7 +5501,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5529,7 +5521,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5550,7 +5541,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5571,7 +5561,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5592,7 +5581,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5613,7 +5601,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5634,7 +5621,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5652,7 +5638,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -11790,7 +11775,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11856,7 +11841,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -15084,7 +15069,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -15852,7 +15836,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/utils/auth/callbacks/get-token.test.ts b/src/utils/auth/callbacks/get-token.test.ts index d25ef6193..65e575c6b 100644 --- a/src/utils/auth/callbacks/get-token.test.ts +++ b/src/utils/auth/callbacks/get-token.test.ts @@ -8,6 +8,10 @@ import { ConfigMock, configBuilder } from "@test-data/config/builders"; import { jwtDecode } from "jwt-decode"; import { Account, Profile } from "next-auth"; import { JWT } from "next-auth/jwt"; +import { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies"; +import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import { cookies } from "next/headers"; +import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; jest.mock("@project/auth", () => ({ auth: jest.fn(), @@ -20,6 +24,11 @@ jest.mock("@src/utils/auth/apim/get-or-refresh-apim-credentials", () => ({ jest.mock("jwt-decode"); jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); jest.mock("@src/utils/config"); +jest.mock("next/headers", () => ({ + cookies: jest.fn(), + headers: jest.fn(), +})); + describe("getToken", () => { const mockedConfig = config as ConfigMock; @@ -55,6 +64,16 @@ describe("getToken", () => { jest.useFakeTimers().setSystemTime(nowInSeconds * 1000); process.env.NEXT_RUNTIME = "nodejs"; + const fakeRequestCookies: ReadonlyRequestCookies = { + get(name: string): RequestCookie { + return { + name: `fake-${name}-name`, + value: `fake-${name}-value`, + }; + }, + } as ReadonlyRequestCookies; + (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); + (jwtDecode as jest.Mock).mockReturnValue({ jti: "jti_test", }); @@ -171,6 +190,54 @@ describe("getToken", () => { maxAgeInSeconds, ); }); + + it("should not return session if signout cookie indicates user has recently signed out", async () => { + const mockSignOutCookie = { + name: SIGNOUT_FLAG_COOKIE_NAME, + value: (nowInSeconds + 60).toString(), + }; + + const fakeRequestCookies: ReadonlyRequestCookies = { + get(name: string): RequestCookie | undefined { + if (name === SIGNOUT_FLAG_COOKIE_NAME) return mockSignOutCookie; + else return { + name: `fake-${name}-name`, + value: `fake-${name}-value`, + }; + }, + } as ReadonlyRequestCookies; + (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); + + const token = { apim: {}, nhs_login: { id_token: "id-token" } } as JWT; + const maxAgeInSeconds = 600 as MaxAgeInSeconds; + + const result = await getToken(token, account, profile, maxAgeInSeconds); + expect(result).toBeNull(); + }); + + it("should ignore signout cookie if its expiry timestamp has passed", async () => { + const mockSignOutCookie = { + name: SIGNOUT_FLAG_COOKIE_NAME, + value: (nowInSeconds - 1).toString(), + }; + + const fakeRequestCookies: ReadonlyRequestCookies = { + get(name: string): RequestCookie | undefined { + if (name === SIGNOUT_FLAG_COOKIE_NAME) return mockSignOutCookie; + return { + name: `fake-${name}-name`, + value: `fake-${name}-value`, + }; + }, + } as ReadonlyRequestCookies; + (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); + + const token = { apim: {}, nhs_login: { id_token: "id-token" } } as JWT; + const maxAgeInSeconds = 600 as MaxAgeInSeconds; + + const result = await getToken(token, account, profile, maxAgeInSeconds); + expect(result).not.toBeNull(); + }); }); describe("when AUTH APIM is not available", () => { @@ -196,6 +263,30 @@ describe("getToken", () => { maxAgeInSeconds, ); }); + + it("should not return session if signout cookie indicates user has recently signed out", async () => { + const mockSignOutCookie = { + name: SIGNOUT_FLAG_COOKIE_NAME, + value: (nowInSeconds + 60).toString(), + }; + + const fakeRequestCookies: ReadonlyRequestCookies = { + get(name: string): RequestCookie | undefined { + if (name === SIGNOUT_FLAG_COOKIE_NAME) return mockSignOutCookie; + else return { + name: `fake-${name}-name`, + value: `fake-${name}-value`, + }; + }, + } as ReadonlyRequestCookies; + (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); + + const token = { apim: {}, nhs_login: { id_token: "id-token" } } as JWT; + const maxAgeInSeconds = 600 as MaxAgeInSeconds; + + const result = await getToken(token, account, profile, maxAgeInSeconds); + expect(result).toBeNull(); + }); }); const expectResultToMatchTokenWith = ( diff --git a/src/utils/auth/callbacks/get-token.ts b/src/utils/auth/callbacks/get-token.ts index a3adca09f..16f7c87d4 100644 --- a/src/utils/auth/callbacks/get-token.ts +++ b/src/utils/auth/callbacks/get-token.ts @@ -4,9 +4,11 @@ import { NhsNumber } from "@src/models/vaccine"; import { getOrRefreshApimCredentials } from "@src/utils/auth/apim/get-or-refresh-apim-credentials"; import { ApimAccessCredentials } from "@src/utils/auth/apim/types"; import { BirthDate, IdToken, MaxAgeInSeconds, NowInSeconds } from "@src/utils/auth/types"; +import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; import { logger } from "@src/utils/logger"; import { Account, Profile } from "next-auth"; import { JWT } from "next-auth/jwt"; +import { cookies } from "next/headers"; import { Logger } from "pino"; const log: Logger = logger.child({ module: "utils-auth-callbacks-get-token" }); @@ -24,13 +26,21 @@ const getToken = async ( profile: Profile | undefined, maxAgeInSeconds: MaxAgeInSeconds, ) => { + const requestCookies = await cookies(); + const nowInSeconds = Math.floor(Date.now() / 1000); + + + //TODO: This should be updated to check the cookie value is associated with the current session + if(requestCookies?.get(SIGNOUT_FLAG_COOKIE_NAME)?.value === "true") { + log.info("getToken: User has recently been signed out. Returning null"); + return null; + } + if (!token) { log.error("getToken: No token available in jwt callback. Returning null"); return null; } - const nowInSeconds = Math.floor(Date.now() / 1000); - // Maximum age reached scenario: invalidate session after fixedExpiry if (token.fixedExpiry && nowInSeconds >= token.fixedExpiry) { log.info("getToken: Token has reached fixedExpiry time, or session has reached the max age. Returning null"); diff --git a/src/utils/auth/inactivity-timer.ts b/src/utils/auth/inactivity-timer.ts index 840c1d782..7cee5fa37 100644 --- a/src/utils/auth/inactivity-timer.ts +++ b/src/utils/auth/inactivity-timer.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; -export const WARNING_TIME_MS: number = 9 * 60 * 1000; -const LOGOUT_TIME_MS: number = 10 * 60 * 1000; +export const WARNING_TIME_MS: number = 1 * 60 * 1000; +const LOGOUT_TIME_MS: number = 2 * 60 * 1000; export const ACTIVITY_EVENTS: string[] = ["keyup", "click", "scroll"]; const useInactivityTimer = (warningTimeMs: number = WARNING_TIME_MS, logoutTimeMs: number = LOGOUT_TIME_MS) => { diff --git a/src/utils/auth/setSignOutFlagCookie.test.ts b/src/utils/auth/setSignOutFlagCookie.test.ts new file mode 100644 index 000000000..f29acbfa5 --- /dev/null +++ b/src/utils/auth/setSignOutFlagCookie.test.ts @@ -0,0 +1,41 @@ +import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; +import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; + +const setCookie = jest.fn(); +jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); +jest.mock("@src/utils/config", () => ({ + __esModule: true, + default: { + MAX_SESSION_AGE_MINUTES: Promise.resolve(2), + }, +})); + +jest.mock("next/headers", () => ({ + cookies: jest.fn(() => ({ + get: jest.fn(), + set: setCookie + })), + headers: jest.fn() +})); + +describe("setSignOutFlagCookie", () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should set signout cookie with expiry duration matching the session max age", async () => { + const expectedCookieTTL = 120; + const expectedCookieValue = (Math.floor(Date.now() / 1000) + expectedCookieTTL).toString(); + await setSignOutFlagCookie(); + expect(setCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, expectedCookieValue, { + maxAge: expectedCookieTTL, + secure: true, + httpOnly: true, + sameSite: "lax" + }); + }); +}); diff --git a/src/utils/auth/setSignOutFlagCookie.ts b/src/utils/auth/setSignOutFlagCookie.ts new file mode 100644 index 000000000..994fab7a8 --- /dev/null +++ b/src/utils/auth/setSignOutFlagCookie.ts @@ -0,0 +1,23 @@ +"use server"; + +import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; +import { cookies } from "next/headers"; +import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; + +const setSignOutFlagCookie = async () => { + return requestScopedStorageWrapper(setSignOutFlagCookieAction); +}; + +const setSignOutFlagCookieAction = async () => { + const cookieStore = await cookies(); + //TODO: Set the value of the cookie to either session-id or the expiry time of the current session. + //TODO: If using session-id/expiry time we can update the maxAge to 30 seconds + cookieStore.set(SIGNOUT_FLAG_COOKIE_NAME, "true", { + secure: true, + httpOnly: true, + sameSite: "lax", + maxAge: 5, + }); +}; + +export default setSignOutFlagCookie; diff --git a/src/utils/auth/user-logout.test.ts b/src/utils/auth/user-logout.test.ts index cbaa85e5b..b0d468c78 100644 --- a/src/utils/auth/user-logout.test.ts +++ b/src/utils/auth/user-logout.test.ts @@ -2,10 +2,13 @@ import { SESSION_LOGOUT_ROUTE } from "@src/app/session-logout/constants"; import { SESSION_TIMEOUT_ROUTE } from "@src/app/session-timeout/constants"; import { userLogout } from "@src/utils/auth/user-logout"; import { signOut } from "next-auth/react"; +import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; jest.mock("next-auth/react", () => ({ signOut: jest.fn(), })); +jest.mock("@src/utils/auth/setSignOutFlagCookie"); +jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); describe("user-logout", () => { it("should call signOut to be redirected to logout page by default", async () => { @@ -25,4 +28,10 @@ describe("user-logout", () => { redirectTo: SESSION_TIMEOUT_ROUTE, }); }); + + it("should setSignOutFlagCookie to prevent race condition with concurrent getSession calls", async() => { + await userLogout(true); + + expect(setSignOutFlagCookie).toHaveBeenCalled(); + }); }); diff --git a/src/utils/auth/user-logout.ts b/src/utils/auth/user-logout.ts index d3d972852..b8cf2fc24 100644 --- a/src/utils/auth/user-logout.ts +++ b/src/utils/auth/user-logout.ts @@ -3,8 +3,10 @@ import { SESSION_LOGOUT_ROUTE } from "@src/app/session-logout/constants"; import { SESSION_TIMEOUT_ROUTE } from "@src/app/session-timeout/constants"; import { signOut } from "next-auth/react"; +import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; const userLogout = async (reasonTimeout: boolean = false) => { + await setSignOutFlagCookie(); await signOut({ redirect: true, redirectTo: reasonTimeout ? SESSION_TIMEOUT_ROUTE : SESSION_LOGOUT_ROUTE, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 2367a1ef4..84936217d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -27,3 +27,4 @@ export const PageviewTypeUrls: Record = { }; export const SESSION_ID_COOKIE_NAME = "__Host-Http-session-id"; +export const SIGNOUT_FLAG_COOKIE_NAME = "__Secure-signout"; From 296e908ac66e820a76889b0b931576d680244262 Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:05:43 +0100 Subject: [PATCH 2/5] set signout cookie flag to session id --- package-lock.json | 21 +++++++- .../inactivity/InactivityDialog.test.tsx | 1 + src/utils/auth/callbacks/get-token.test.ts | 50 ++++++------------- src/utils/auth/callbacks/get-token.ts | 14 +++--- src/utils/auth/inactivity-timer.ts | 4 +- src/utils/auth/setSignOutFlagCookie.test.ts | 25 ++++++---- src/utils/auth/setSignOutFlagCookie.ts | 10 ++-- 7 files changed, 64 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e3ee3df9..f54a9e104 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5342,6 +5342,7 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5381,6 +5382,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5401,6 +5403,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5421,6 +5424,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5441,6 +5445,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5461,6 +5466,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5481,6 +5487,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5501,6 +5508,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5521,6 +5529,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5541,6 +5550,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5561,6 +5571,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5581,6 +5592,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5601,6 +5613,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5621,6 +5634,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5638,6 +5652,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -11775,7 +11790,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11841,7 +11856,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -15069,6 +15084,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -15836,6 +15852,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/_components/inactivity/InactivityDialog.test.tsx b/src/app/_components/inactivity/InactivityDialog.test.tsx index 003ccb97a..bca40433a 100644 --- a/src/app/_components/inactivity/InactivityDialog.test.tsx +++ b/src/app/_components/inactivity/InactivityDialog.test.tsx @@ -25,6 +25,7 @@ jest.mock("next/navigation", () => ({ usePathname: jest.fn(() => mockUrlPath), })); +jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); jest.mock("@src/utils/auth/inactivity-timer"); jest.mock("@src/utils/auth/user-logout"); diff --git a/src/utils/auth/callbacks/get-token.test.ts b/src/utils/auth/callbacks/get-token.test.ts index 65e575c6b..a37ab166e 100644 --- a/src/utils/auth/callbacks/get-token.test.ts +++ b/src/utils/auth/callbacks/get-token.test.ts @@ -4,6 +4,7 @@ import { getOrRefreshApimCredentials } from "@src/utils/auth/apim/get-or-refresh import { getToken } from "@src/utils/auth/callbacks/get-token"; import { MaxAgeInSeconds } from "@src/utils/auth/types"; import config from "@src/utils/config"; +import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; import { ConfigMock, configBuilder } from "@test-data/config/builders"; import { jwtDecode } from "jwt-decode"; import { Account, Profile } from "next-auth"; @@ -11,7 +12,6 @@ import { JWT } from "next-auth/jwt"; import { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies"; import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import { cookies } from "next/headers"; -import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; jest.mock("@project/auth", () => ({ auth: jest.fn(), @@ -29,7 +29,6 @@ jest.mock("next/headers", () => ({ headers: jest.fn(), })); - describe("getToken", () => { const mockedConfig = config as ConfigMock; @@ -191,19 +190,13 @@ describe("getToken", () => { ); }); - it("should not return session if signout cookie indicates user has recently signed out", async () => { - const mockSignOutCookie = { - name: SIGNOUT_FLAG_COOKIE_NAME, - value: (nowInSeconds + 60).toString(), - }; - + it("should not return session if signout cookie value matches current session id", async () => { + const mockSessionId = "test-session-id"; const fakeRequestCookies: ReadonlyRequestCookies = { get(name: string): RequestCookie | undefined { - if (name === SIGNOUT_FLAG_COOKIE_NAME) return mockSignOutCookie; - else return { - name: `fake-${name}-name`, - value: `fake-${name}-value`, - }; + if (name === SIGNOUT_FLAG_COOKIE_NAME) return { name: SIGNOUT_FLAG_COOKIE_NAME, value: mockSessionId }; + if (name === SESSION_ID_COOKIE_NAME) return { name: SESSION_ID_COOKIE_NAME, value: mockSessionId }; + return { name: `fake-${name}-name`, value: `fake-${name}-value` }; }, } as ReadonlyRequestCookies; (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); @@ -215,19 +208,12 @@ describe("getToken", () => { expect(result).toBeNull(); }); - it("should ignore signout cookie if its expiry timestamp has passed", async () => { - const mockSignOutCookie = { - name: SIGNOUT_FLAG_COOKIE_NAME, - value: (nowInSeconds - 1).toString(), - }; - + it("should ignore signout cookie if its value does not match current session id", async () => { const fakeRequestCookies: ReadonlyRequestCookies = { get(name: string): RequestCookie | undefined { - if (name === SIGNOUT_FLAG_COOKIE_NAME) return mockSignOutCookie; - return { - name: `fake-${name}-name`, - value: `fake-${name}-value`, - }; + if (name === SIGNOUT_FLAG_COOKIE_NAME) return { name: SIGNOUT_FLAG_COOKIE_NAME, value: "old-session-id" }; + if (name === SESSION_ID_COOKIE_NAME) return { name: SESSION_ID_COOKIE_NAME, value: "current-session-id" }; + return { name: `fake-${name}-name`, value: `fake-${name}-value` }; }, } as ReadonlyRequestCookies; (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); @@ -264,19 +250,13 @@ describe("getToken", () => { ); }); - it("should not return session if signout cookie indicates user has recently signed out", async () => { - const mockSignOutCookie = { - name: SIGNOUT_FLAG_COOKIE_NAME, - value: (nowInSeconds + 60).toString(), - }; - + it("should not return session if signout cookie value matches current session id", async () => { + const mockSessionId = "test-session-id"; const fakeRequestCookies: ReadonlyRequestCookies = { get(name: string): RequestCookie | undefined { - if (name === SIGNOUT_FLAG_COOKIE_NAME) return mockSignOutCookie; - else return { - name: `fake-${name}-name`, - value: `fake-${name}-value`, - }; + if (name === SIGNOUT_FLAG_COOKIE_NAME) return { name: SIGNOUT_FLAG_COOKIE_NAME, value: mockSessionId }; + if (name === SESSION_ID_COOKIE_NAME) return { name: SESSION_ID_COOKIE_NAME, value: mockSessionId }; + return { name: `fake-${name}-name`, value: `fake-${name}-value` }; }, } as ReadonlyRequestCookies; (cookies as jest.Mock).mockResolvedValue(fakeRequestCookies); diff --git a/src/utils/auth/callbacks/get-token.ts b/src/utils/auth/callbacks/get-token.ts index 16f7c87d4..416010676 100644 --- a/src/utils/auth/callbacks/get-token.ts +++ b/src/utils/auth/callbacks/get-token.ts @@ -4,7 +4,7 @@ import { NhsNumber } from "@src/models/vaccine"; import { getOrRefreshApimCredentials } from "@src/utils/auth/apim/get-or-refresh-apim-credentials"; import { ApimAccessCredentials } from "@src/utils/auth/apim/types"; import { BirthDate, IdToken, MaxAgeInSeconds, NowInSeconds } from "@src/utils/auth/types"; -import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; +import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; import { logger } from "@src/utils/logger"; import { Account, Profile } from "next-auth"; import { JWT } from "next-auth/jwt"; @@ -29,12 +29,12 @@ const getToken = async ( const requestCookies = await cookies(); const nowInSeconds = Math.floor(Date.now() / 1000); - - //TODO: This should be updated to check the cookie value is associated with the current session - if(requestCookies?.get(SIGNOUT_FLAG_COOKIE_NAME)?.value === "true") { - log.info("getToken: User has recently been signed out. Returning null"); - return null; - } + const signOutFlagValue = requestCookies?.get(SIGNOUT_FLAG_COOKIE_NAME)?.value; + const currentSessionId = requestCookies?.get(SESSION_ID_COOKIE_NAME)?.value; + if (signOutFlagValue && currentSessionId && signOutFlagValue === currentSessionId) { + log.info("getToken: User has recently been signed out. Returning null"); + return null; + } if (!token) { log.error("getToken: No token available in jwt callback. Returning null"); diff --git a/src/utils/auth/inactivity-timer.ts b/src/utils/auth/inactivity-timer.ts index 7cee5fa37..840c1d782 100644 --- a/src/utils/auth/inactivity-timer.ts +++ b/src/utils/auth/inactivity-timer.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; -export const WARNING_TIME_MS: number = 1 * 60 * 1000; -const LOGOUT_TIME_MS: number = 2 * 60 * 1000; +export const WARNING_TIME_MS: number = 9 * 60 * 1000; +const LOGOUT_TIME_MS: number = 10 * 60 * 1000; export const ACTIVITY_EVENTS: string[] = ["keyup", "click", "scroll"]; const useInactivityTimer = (warningTimeMs: number = WARNING_TIME_MS, logoutTimeMs: number = LOGOUT_TIME_MS) => { diff --git a/src/utils/auth/setSignOutFlagCookie.test.ts b/src/utils/auth/setSignOutFlagCookie.test.ts index f29acbfa5..b04494d25 100644 --- a/src/utils/auth/setSignOutFlagCookie.test.ts +++ b/src/utils/auth/setSignOutFlagCookie.test.ts @@ -1,6 +1,7 @@ import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; -import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; +import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; +const mockSessionId = "session-id-123"; const setCookie = jest.fn(); jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); jest.mock("@src/utils/config", () => ({ @@ -12,10 +13,15 @@ jest.mock("@src/utils/config", () => ({ jest.mock("next/headers", () => ({ cookies: jest.fn(() => ({ - get: jest.fn(), - set: setCookie + get: jest.fn((name) => { + if (name === SESSION_ID_COOKIE_NAME) { + return { value: mockSessionId }; + } + return undefined; + }), + set: setCookie, })), - headers: jest.fn() + headers: jest.fn(), })); describe("setSignOutFlagCookie", () => { @@ -27,15 +33,14 @@ describe("setSignOutFlagCookie", () => { jest.useRealTimers(); }); - it("should set signout cookie with expiry duration matching the session max age", async () => { - const expectedCookieTTL = 120; - const expectedCookieValue = (Math.floor(Date.now() / 1000) + expectedCookieTTL).toString(); + it("should set signout cookie with the current session id", async () => { await setSignOutFlagCookie(); - expect(setCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, expectedCookieValue, { - maxAge: expectedCookieTTL, + const expectedCookieTimeoutSeconds = 30; + expect(setCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, mockSessionId, { + maxAge: expectedCookieTimeoutSeconds, secure: true, httpOnly: true, - sameSite: "lax" + sameSite: "lax", }); }); }); diff --git a/src/utils/auth/setSignOutFlagCookie.ts b/src/utils/auth/setSignOutFlagCookie.ts index 994fab7a8..fa7c75c5d 100644 --- a/src/utils/auth/setSignOutFlagCookie.ts +++ b/src/utils/auth/setSignOutFlagCookie.ts @@ -1,22 +1,22 @@ "use server"; +import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; import { cookies } from "next/headers"; -import { SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; const setSignOutFlagCookie = async () => { return requestScopedStorageWrapper(setSignOutFlagCookieAction); }; const setSignOutFlagCookieAction = async () => { + const SIGN_OUT_FLAG_COOKIE_MAX_AGE_SECONDS = 30; const cookieStore = await cookies(); - //TODO: Set the value of the cookie to either session-id or the expiry time of the current session. - //TODO: If using session-id/expiry time we can update the maxAge to 30 seconds - cookieStore.set(SIGNOUT_FLAG_COOKIE_NAME, "true", { + const currentSessionId = cookieStore.get(SESSION_ID_COOKIE_NAME)?.value ?? ""; + cookieStore.set(SIGNOUT_FLAG_COOKIE_NAME, currentSessionId, { secure: true, httpOnly: true, sameSite: "lax", - maxAge: 5, + maxAge: SIGN_OUT_FLAG_COOKIE_MAX_AGE_SECONDS, }); }; From de5a9819b385d3d886d49a5b8d04b970fc44828c Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:47:54 +0100 Subject: [PATCH 3/5] add info on new cookie for user --- src/app/our-policies/cookies-policy/CookiesTable.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/our-policies/cookies-policy/CookiesTable.tsx b/src/app/our-policies/cookies-policy/CookiesTable.tsx index 39e345f24..bc1e318aa 100644 --- a/src/app/our-policies/cookies-policy/CookiesTable.tsx +++ b/src/app/our-policies/cookies-policy/CookiesTable.tsx @@ -54,6 +54,16 @@ const CookiesTable = (): JSX.Element => { After 1 hour + + + __Secure-signout + + + Stores temporary information used to identify when you sign out or are signed out after a period of + inactivity. + + After 30 seconds + ); From 1f862120317331740e0f3c8aff8adf4a6649abf7 Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:33:18 +0100 Subject: [PATCH 4/5] add cookie table test Co-authored-by: Copilot --- .../cookies-policy/CookiesTable.test.tsx | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/app/our-policies/cookies-policy/CookiesTable.test.tsx b/src/app/our-policies/cookies-policy/CookiesTable.test.tsx index 547edcc57..c4d594ef0 100644 --- a/src/app/our-policies/cookies-policy/CookiesTable.test.tsx +++ b/src/app/our-policies/cookies-policy/CookiesTable.test.tsx @@ -18,34 +18,47 @@ describe("CookiesTable component", () => { const rowHeader1: HTMLElement = screen.getByRole("rowheader", { name: "__Host-authjs.csrf-token" }); const rowHeader2: HTMLElement = screen.getByRole("rowheader", { name: "__Secure-authjs.callback-url" }); const rowHeader3: HTMLElement = screen.getByRole("rowheader", { name: "__Secure-authjs.session-token" }); + const rowHeader4: HTMLElement = screen.getByRole("rowheader", { name: "__Host-Http-session-id" }); + const rowHeader5: HTMLElement = screen.getByRole("rowheader", { name: "__Secure-signout" }); expect(rowHeader1).toBeVisible(); expect(rowHeader2).toBeVisible(); expect(rowHeader3).toBeVisible(); + expect(rowHeader4).toBeVisible(); + expect(rowHeader5).toBeVisible(); }); it("displays table with correct cell values", () => { render(); - const cell1: HTMLElement = screen.getByRole("cell", { + + const csrfCookieText: HTMLElement = screen.getByRole("cell", { name: "Helps keep the site secure by preventing cross-site request forgery (CSRF) attacks", }); - const cell2: HTMLElement = screen.getByRole("cell", { + const redirectUrlCookieText: HTMLElement = screen.getByRole("cell", { name: "After a successful login, this stores the URL that you are redirected to", }); - const cell3: HTMLElement = screen.getByRole("cell", { + const encryptedSessionTokenCookieText: HTMLElement = screen.getByRole("cell", { name: "Stores information in an encrypted format that allows us to communicate with other services", }); - const cell4: HTMLElement = screen.getByRole("cell", { + const sessionIdCookieText: HTMLElement = screen.getByRole("cell", { name: "Stores a unique, randomly generated session ID used in operational logs to help our IT support team investigate issues", }); - const cells5and6: HTMLElement[] = screen.getAllByRole("cell", { name: "When you close the browser" }); - const cells7and8: HTMLElement[] = screen.getAllByRole("cell", { name: "After 1 hour" }); - - expect(cell1).toBeVisible(); - expect(cell2).toBeVisible(); - expect(cell3).toBeVisible(); - expect(cell4).toBeVisible(); - expect(cells5and6.length).toBe(2); - expect(cells7and8.length).toBe(2); + const signoutCookieText: HTMLElement = screen.getByRole("cell", { + name: "Stores temporary information used to identify when you sign out or are signed out after a period of inactivity.", + }); + + const onBrowserCloseCookieTime: HTMLElement[] = screen.getAllByRole("cell", { name: "When you close the browser" }); + const after1hourCookieTime: HTMLElement[] = screen.getAllByRole("cell", { name: "After 1 hour" }); + const after30sCookieTime: HTMLElement[] = screen.getAllByRole("cell", { name: "After 30 seconds" }); + + expect(csrfCookieText).toBeVisible(); + expect(redirectUrlCookieText).toBeVisible(); + expect(encryptedSessionTokenCookieText).toBeVisible(); + expect(sessionIdCookieText).toBeVisible(); + expect(signoutCookieText).toBeVisible(); + + expect(onBrowserCloseCookieTime.length).toBe(2); + expect(after1hourCookieTime.length).toBe(2); + expect(after30sCookieTime.length).toBe(1); }); }); From b8a2c56da6b4c7d36e368fc72aacf39079585343 Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:39:40 +0100 Subject: [PATCH 5/5] update cookie info to reflect final design --- src/app/our-policies/cookies-policy/CookiesTable.test.tsx | 2 +- src/app/our-policies/cookies-policy/CookiesTable.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/our-policies/cookies-policy/CookiesTable.test.tsx b/src/app/our-policies/cookies-policy/CookiesTable.test.tsx index c4d594ef0..4b7c008fa 100644 --- a/src/app/our-policies/cookies-policy/CookiesTable.test.tsx +++ b/src/app/our-policies/cookies-policy/CookiesTable.test.tsx @@ -44,7 +44,7 @@ describe("CookiesTable component", () => { name: "Stores a unique, randomly generated session ID used in operational logs to help our IT support team investigate issues", }); const signoutCookieText: HTMLElement = screen.getByRole("cell", { - name: "Stores temporary information used to identify when you sign out or are signed out after a period of inactivity.", + name: "This cookie is used when you sign out or after a period of inactivity. It temporarily stores the session ID to help securely end your session and keep your information secure.", }); const onBrowserCloseCookieTime: HTMLElement[] = screen.getAllByRole("cell", { name: "When you close the browser" }); diff --git a/src/app/our-policies/cookies-policy/CookiesTable.tsx b/src/app/our-policies/cookies-policy/CookiesTable.tsx index bc1e318aa..50b186452 100644 --- a/src/app/our-policies/cookies-policy/CookiesTable.tsx +++ b/src/app/our-policies/cookies-policy/CookiesTable.tsx @@ -59,8 +59,8 @@ const CookiesTable = (): JSX.Element => { __Secure-signout - Stores temporary information used to identify when you sign out or are signed out after a period of - inactivity. + This cookie is used when you sign out or after a period of inactivity. It temporarily stores the session ID + to help securely end your session and keep your information secure. After 30 seconds