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 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 From 209c48c52241b8e63a9dc570c2fd960b4d1c29dd Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:30:22 +0100 Subject: [PATCH 06/13] update cookie info to reflect final design 2 --- 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 4b7c008fa..92e0773fe 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: "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.", + name: "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 50b186452..eb313744c 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 - 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. + 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 From 2f54fb7645d98738a0e60220e8d44d5e69003b03 Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:10:01 +0100 Subject: [PATCH 07/13] replace sanitize-data with missing dependency mocks --- src/app/_components/inactivity/InactivityDialog.test.tsx | 5 ++++- src/utils/auth/setSignOutFlagCookie.test.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/_components/inactivity/InactivityDialog.test.tsx b/src/app/_components/inactivity/InactivityDialog.test.tsx index bca40433a..2ca6e8a28 100644 --- a/src/app/_components/inactivity/InactivityDialog.test.tsx +++ b/src/app/_components/inactivity/InactivityDialog.test.tsx @@ -25,7 +25,10 @@ jest.mock("next/navigation", () => ({ usePathname: jest.fn(() => mockUrlPath), })); -jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); +jest.mock("@src/utils/auth/user-logout", () => ({ + userLogout: jest.fn(), +})); + jest.mock("@src/utils/auth/inactivity-timer"); jest.mock("@src/utils/auth/user-logout"); diff --git a/src/utils/auth/setSignOutFlagCookie.test.ts b/src/utils/auth/setSignOutFlagCookie.test.ts index b04494d25..6f8cdae0f 100644 --- a/src/utils/auth/setSignOutFlagCookie.test.ts +++ b/src/utils/auth/setSignOutFlagCookie.test.ts @@ -3,7 +3,9 @@ import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/con const mockSessionId = "session-id-123"; const setCookie = jest.fn(); -jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); +jest.mock("@src/utils/requestScopedStorageWrapper", () => ({ + requestScopedStorageWrapper: jest.fn((fn) => fn()), +})); jest.mock("@src/utils/config", () => ({ __esModule: true, default: { From 25236767894ae6ace8890c1156267b01b9bb8b3d Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:30:26 +0100 Subject: [PATCH 08/13] tidy up tests Co-authored-by: Copilot --- src/utils/auth/callbacks/get-token.test.ts | 46 +++++++++++----------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/utils/auth/callbacks/get-token.test.ts b/src/utils/auth/callbacks/get-token.test.ts index a37ab166e..2040db563 100644 --- a/src/utils/auth/callbacks/get-token.test.ts +++ b/src/utils/auth/callbacks/get-token.test.ts @@ -190,12 +190,29 @@ describe("getToken", () => { ); }); - it("should not return session if signout cookie value matches current session id", async () => { - const mockSessionId = "test-session-id"; + it.each<{ + signoutCookieValue: string; + sessionIdCookieValue: string; + shouldBeNull: boolean; + description: string; + }>([ + { + signoutCookieValue: "test-session-id", + sessionIdCookieValue: "test-session-id", + shouldBeNull: true, + description: "should return null when signout cookie matches current session id", + }, + { + signoutCookieValue: "old-session-id", + sessionIdCookieValue: "current-session-id", + shouldBeNull: false, + description: "should return token when signout cookie does not match current session id", + }, + ])("$description", async ({ signoutCookieValue, sessionIdCookieValue, shouldBeNull }) => { const fakeRequestCookies: ReadonlyRequestCookies = { get(name: string): RequestCookie | undefined { - 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 }; + if (name === SIGNOUT_FLAG_COOKIE_NAME) return { name: SIGNOUT_FLAG_COOKIE_NAME, value: signoutCookieValue }; + if (name === SESSION_ID_COOKIE_NAME) return { name: SESSION_ID_COOKIE_NAME, value: sessionIdCookieValue }; return { name: `fake-${name}-name`, value: `fake-${name}-value` }; }, } as ReadonlyRequestCookies; @@ -205,24 +222,8 @@ describe("getToken", () => { const maxAgeInSeconds = 600 as MaxAgeInSeconds; const result = await getToken(token, account, profile, maxAgeInSeconds); - expect(result).toBeNull(); - }); - 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 { 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); - - 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(); + expect(result === null).toBe(shouldBeNull); }); }); @@ -250,7 +251,7 @@ describe("getToken", () => { ); }); - it("should not return session if signout cookie value matches current session id", async () => { + it("should return null when signout cookie matches current session id", async () => { const mockSessionId = "test-session-id"; const fakeRequestCookies: ReadonlyRequestCookies = { get(name: string): RequestCookie | undefined { @@ -265,6 +266,7 @@ describe("getToken", () => { const maxAgeInSeconds = 600 as MaxAgeInSeconds; const result = await getToken(token, account, profile, maxAgeInSeconds); + expect(result).toBeNull(); }); }); From acce62c0543cf2a237aba190016340067bb68195 Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:50:29 +0100 Subject: [PATCH 09/13] wrap userLogout action - logout and set cookie - in request context Co-authored-by: Copilot --- src/utils/auth/user-logout.test.ts | 19 +++++++++++++++---- src/utils/auth/user-logout.ts | 10 ++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/utils/auth/user-logout.test.ts b/src/utils/auth/user-logout.test.ts index b0d468c78..a608625e8 100644 --- a/src/utils/auth/user-logout.test.ts +++ b/src/utils/auth/user-logout.test.ts @@ -1,13 +1,18 @@ 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"; +import { userLogout } from "@src/utils/auth/user-logout"; +import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; -jest.mock("next-auth/react", () => ({ +import { signOut } from "../../../auth"; + +jest.mock("../../../auth", () => ({ signOut: jest.fn(), })); jest.mock("@src/utils/auth/setSignOutFlagCookie"); +jest.mock("@src/utils/requestScopedStorageWrapper", () => ({ + requestScopedStorageWrapper: jest.fn((fn, ...args) => fn(...args)), +})); jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); describe("user-logout", () => { @@ -29,9 +34,15 @@ describe("user-logout", () => { }); }); - it("should setSignOutFlagCookie to prevent race condition with concurrent getSession calls", async() => { + it("should setSignOutFlagCookie to prevent race condition with concurrent getSession calls", async () => { await userLogout(true); expect(setSignOutFlagCookie).toHaveBeenCalled(); }); + + it("should wrap logout flow in request scoped storage", async () => { + await userLogout(true); + + expect(requestScopedStorageWrapper).toHaveBeenCalled(); + }); }); diff --git a/src/utils/auth/user-logout.ts b/src/utils/auth/user-logout.ts index b8cf2fc24..794c62f9f 100644 --- a/src/utils/auth/user-logout.ts +++ b/src/utils/auth/user-logout.ts @@ -1,11 +1,17 @@ -"use client"; +"use server"; 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"; +import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; + +import { signOut } from "../../../auth"; const userLogout = async (reasonTimeout: boolean = false) => { + return requestScopedStorageWrapper(userLogoutAction, reasonTimeout); +}; + +const userLogoutAction = async (reasonTimeout: boolean = false) => { await setSignOutFlagCookie(); await signOut({ redirect: true, From bd12691d33c3f0acfb72011737f4b6f63d9f9865 Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:14:47 +0100 Subject: [PATCH 10/13] copilot comments Co-authored-by: Copilot --- .../inactivity/InactivityDialog.test.tsx | 1 - src/utils/auth/callbacks/get-token.ts | 10 ++-- src/utils/auth/setSignOutFlagCookie.test.ts | 57 ++++++++++++++----- src/utils/auth/setSignOutFlagCookie.ts | 8 +++ src/utils/auth/user-logout.test.ts | 5 +- src/utils/auth/user-logout.ts | 3 +- 6 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/app/_components/inactivity/InactivityDialog.test.tsx b/src/app/_components/inactivity/InactivityDialog.test.tsx index 2ca6e8a28..42b458286 100644 --- a/src/app/_components/inactivity/InactivityDialog.test.tsx +++ b/src/app/_components/inactivity/InactivityDialog.test.tsx @@ -30,7 +30,6 @@ jest.mock("@src/utils/auth/user-logout", () => ({ })); jest.mock("@src/utils/auth/inactivity-timer"); -jest.mock("@src/utils/auth/user-logout"); let idleSession = false; let timedOutSession = false; diff --git a/src/utils/auth/callbacks/get-token.ts b/src/utils/auth/callbacks/get-token.ts index 416010676..93e4b255f 100644 --- a/src/utils/auth/callbacks/get-token.ts +++ b/src/utils/auth/callbacks/get-token.ts @@ -26,6 +26,11 @@ const getToken = async ( profile: Profile | undefined, maxAgeInSeconds: MaxAgeInSeconds, ) => { + if (!token) { + log.error("getToken: No token available in jwt callback. Returning null"); + return null; + } + const requestCookies = await cookies(); const nowInSeconds = Math.floor(Date.now() / 1000); @@ -36,11 +41,6 @@ const getToken = async ( return null; } - if (!token) { - log.error("getToken: No token available in jwt callback. Returning null"); - return null; - } - // 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/setSignOutFlagCookie.test.ts b/src/utils/auth/setSignOutFlagCookie.test.ts index 6f8cdae0f..e083966a2 100644 --- a/src/utils/auth/setSignOutFlagCookie.test.ts +++ b/src/utils/auth/setSignOutFlagCookie.test.ts @@ -3,6 +3,7 @@ import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/con const mockSessionId = "session-id-123"; const setCookie = jest.fn(); + jest.mock("@src/utils/requestScopedStorageWrapper", () => ({ requestScopedStorageWrapper: jest.fn((fn) => fn()), })); @@ -12,30 +13,45 @@ jest.mock("@src/utils/config", () => ({ MAX_SESSION_AGE_MINUTES: Promise.resolve(2), }, })); +jest.mock("@src/utils/logger", () => ({ + mockWarn: jest.fn(), + logger: { + child: jest.fn(() => { + const mockedLoggerModule = jest.requireMock("@src/utils/logger") as { mockWarn: jest.Mock }; + return { warn: mockedLoggerModule.mockWarn }; + }), + }, +})); + +let mockGetCookie = (name: string) => { + if (name === SESSION_ID_COOKIE_NAME) { + return { value: mockSessionId }; + } + return undefined; +}; jest.mock("next/headers", () => ({ cookies: jest.fn(() => ({ - get: jest.fn((name) => { - if (name === SESSION_ID_COOKIE_NAME) { - return { value: mockSessionId }; - } - return undefined; - }), + get: jest.fn((name) => mockGetCookie(name)), set: setCookie, })), headers: jest.fn(), })); describe("setSignOutFlagCookie", () => { - beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - }); - - afterEach(() => { - jest.useRealTimers(); - }); + const getLoggerWarnMock = (): jest.Mock => { + const mockedLoggerModule = jest.requireMock("@src/utils/logger") as { mockWarn: jest.Mock }; + return mockedLoggerModule.mockWarn; + }; it("should set signout cookie with the current session id", async () => { + mockGetCookie = (name: string) => { + if (name === SESSION_ID_COOKIE_NAME) { + return { value: mockSessionId }; + } + return undefined; + }; + setCookie.mockClear(); await setSignOutFlagCookie(); const expectedCookieTimeoutSeconds = 30; expect(setCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, mockSessionId, { @@ -43,6 +59,21 @@ describe("setSignOutFlagCookie", () => { secure: true, httpOnly: true, sameSite: "lax", + path: "/", }); }); + + it("should not set signout cookie if session id is missing", async () => { + mockGetCookie = () => { + return undefined; + }; + setCookie.mockClear(); + const warnMock = getLoggerWarnMock(); + warnMock.mockClear(); + + await setSignOutFlagCookie(); + + expect(setCookie).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledWith("Session ID missing, skipping signout cookie"); + }); }); diff --git a/src/utils/auth/setSignOutFlagCookie.ts b/src/utils/auth/setSignOutFlagCookie.ts index fa7c75c5d..cf13b9203 100644 --- a/src/utils/auth/setSignOutFlagCookie.ts +++ b/src/utils/auth/setSignOutFlagCookie.ts @@ -1,9 +1,12 @@ "use server"; import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; +import { logger } from "@src/utils/logger"; import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; import { cookies } from "next/headers"; +const log = logger.child({ module: "utils-auth-setSignOutFlagCookie" }); + const setSignOutFlagCookie = async () => { return requestScopedStorageWrapper(setSignOutFlagCookieAction); }; @@ -12,10 +15,15 @@ const setSignOutFlagCookieAction = async () => { const SIGN_OUT_FLAG_COOKIE_MAX_AGE_SECONDS = 30; const cookieStore = await cookies(); const currentSessionId = cookieStore.get(SESSION_ID_COOKIE_NAME)?.value ?? ""; + if (!currentSessionId) { + log.warn("Session ID missing, skipping signout cookie"); + return; + } cookieStore.set(SIGNOUT_FLAG_COOKIE_NAME, currentSessionId, { secure: true, httpOnly: true, sameSite: "lax", + path: "/", maxAge: SIGN_OUT_FLAG_COOKIE_MAX_AGE_SECONDS, }); }; diff --git a/src/utils/auth/user-logout.test.ts b/src/utils/auth/user-logout.test.ts index a608625e8..2622877d9 100644 --- a/src/utils/auth/user-logout.test.ts +++ b/src/utils/auth/user-logout.test.ts @@ -1,12 +1,11 @@ +import { signOut } from "@project/auth"; import { SESSION_LOGOUT_ROUTE } from "@src/app/session-logout/constants"; import { SESSION_TIMEOUT_ROUTE } from "@src/app/session-timeout/constants"; import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; import { userLogout } from "@src/utils/auth/user-logout"; import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; -import { signOut } from "../../../auth"; - -jest.mock("../../../auth", () => ({ +jest.mock("@project/auth", () => ({ signOut: jest.fn(), })); jest.mock("@src/utils/auth/setSignOutFlagCookie"); diff --git a/src/utils/auth/user-logout.ts b/src/utils/auth/user-logout.ts index 794c62f9f..a79f023cf 100644 --- a/src/utils/auth/user-logout.ts +++ b/src/utils/auth/user-logout.ts @@ -1,12 +1,11 @@ "use server"; +import { signOut } from "@project/auth"; import { SESSION_LOGOUT_ROUTE } from "@src/app/session-logout/constants"; import { SESSION_TIMEOUT_ROUTE } from "@src/app/session-timeout/constants"; import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; -import { signOut } from "../../../auth"; - const userLogout = async (reasonTimeout: boolean = false) => { return requestScopedStorageWrapper(userLogoutAction, reasonTimeout); }; From ddf571b643bf97cb7a5f0ed31a489b30afd012df Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:49:17 +0100 Subject: [PATCH 11/13] move signout back to client side to match the UI handling as is Co-authored-by: Copilot --- src/utils/auth/user-logout.test.ts | 14 ++------------ src/utils/auth/user-logout.ts | 9 ++------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/utils/auth/user-logout.test.ts b/src/utils/auth/user-logout.test.ts index 2622877d9..245d225c4 100644 --- a/src/utils/auth/user-logout.test.ts +++ b/src/utils/auth/user-logout.test.ts @@ -1,17 +1,13 @@ -import { signOut } from "@project/auth"; import { SESSION_LOGOUT_ROUTE } from "@src/app/session-logout/constants"; import { SESSION_TIMEOUT_ROUTE } from "@src/app/session-timeout/constants"; import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; import { userLogout } from "@src/utils/auth/user-logout"; -import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; +import { signOut } from "next-auth/react"; -jest.mock("@project/auth", () => ({ +jest.mock("next-auth/react", () => ({ signOut: jest.fn(), })); jest.mock("@src/utils/auth/setSignOutFlagCookie"); -jest.mock("@src/utils/requestScopedStorageWrapper", () => ({ - requestScopedStorageWrapper: jest.fn((fn, ...args) => fn(...args)), -})); jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); describe("user-logout", () => { @@ -38,10 +34,4 @@ describe("user-logout", () => { expect(setSignOutFlagCookie).toHaveBeenCalled(); }); - - it("should wrap logout flow in request scoped storage", async () => { - await userLogout(true); - - expect(requestScopedStorageWrapper).toHaveBeenCalled(); - }); }); diff --git a/src/utils/auth/user-logout.ts b/src/utils/auth/user-logout.ts index a79f023cf..6b42a6092 100644 --- a/src/utils/auth/user-logout.ts +++ b/src/utils/auth/user-logout.ts @@ -1,16 +1,11 @@ -"use server"; +"use client"; -import { signOut } from "@project/auth"; import { SESSION_LOGOUT_ROUTE } from "@src/app/session-logout/constants"; import { SESSION_TIMEOUT_ROUTE } from "@src/app/session-timeout/constants"; import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; -import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; +import { signOut } from "next-auth/react"; const userLogout = async (reasonTimeout: boolean = false) => { - return requestScopedStorageWrapper(userLogoutAction, reasonTimeout); -}; - -const userLogoutAction = async (reasonTimeout: boolean = false) => { await setSignOutFlagCookie(); await signOut({ redirect: true, From e868a8a87180ab0b371da1717c511922dacbe6cb Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Mon, 4 May 2026 17:52:39 +0100 Subject: [PATCH 12/13] review comment tidy up Co-authored-by: Copilot --- src/utils/auth/callbacks/get-token.test.ts | 19 -------- src/utils/auth/setSignOutFlagCookie.test.ts | 51 ++++++++------------- 2 files changed, 19 insertions(+), 51 deletions(-) diff --git a/src/utils/auth/callbacks/get-token.test.ts b/src/utils/auth/callbacks/get-token.test.ts index 2040db563..3857a40dd 100644 --- a/src/utils/auth/callbacks/get-token.test.ts +++ b/src/utils/auth/callbacks/get-token.test.ts @@ -250,25 +250,6 @@ describe("getToken", () => { maxAgeInSeconds, ); }); - - it("should return null when signout cookie 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 { 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); - - 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/setSignOutFlagCookie.test.ts b/src/utils/auth/setSignOutFlagCookie.test.ts index e083966a2..a988e0d42 100644 --- a/src/utils/auth/setSignOutFlagCookie.test.ts +++ b/src/utils/auth/setSignOutFlagCookie.test.ts @@ -1,18 +1,13 @@ import setSignOutFlagCookie from "@src/utils/auth/setSignOutFlagCookie"; import { SESSION_ID_COOKIE_NAME, SIGNOUT_FLAG_COOKIE_NAME } from "@src/utils/constants"; -const mockSessionId = "session-id-123"; -const setCookie = jest.fn(); +const mockSetCookie = jest.fn(); +const mockGetCookie = jest.fn(); jest.mock("@src/utils/requestScopedStorageWrapper", () => ({ requestScopedStorageWrapper: jest.fn((fn) => fn()), })); -jest.mock("@src/utils/config", () => ({ - __esModule: true, - default: { - MAX_SESSION_AGE_MINUTES: Promise.resolve(2), - }, -})); + jest.mock("@src/utils/logger", () => ({ mockWarn: jest.fn(), logger: { @@ -23,38 +18,34 @@ jest.mock("@src/utils/logger", () => ({ }, })); -let mockGetCookie = (name: string) => { - if (name === SESSION_ID_COOKIE_NAME) { - return { value: mockSessionId }; - } - return undefined; -}; - jest.mock("next/headers", () => ({ cookies: jest.fn(() => ({ - get: jest.fn((name) => mockGetCookie(name)), - set: setCookie, + get: mockGetCookie, + set: mockSetCookie, })), headers: jest.fn(), })); describe("setSignOutFlagCookie", () => { + const mockSessionId = "session-id-123"; const getLoggerWarnMock = (): jest.Mock => { const mockedLoggerModule = jest.requireMock("@src/utils/logger") as { mockWarn: jest.Mock }; return mockedLoggerModule.mockWarn; }; - it("should set signout cookie with the current session id", async () => { - mockGetCookie = (name: string) => { - if (name === SESSION_ID_COOKIE_NAME) { - return { value: mockSessionId }; - } + beforeEach(() => { + jest.clearAllMocks(); + mockGetCookie.mockImplementation((name: string) => { + if (name === SESSION_ID_COOKIE_NAME) return { value: mockSessionId }; return undefined; - }; - setCookie.mockClear(); + }); + }); + + it("should set signout cookie with the current session id", async () => { await setSignOutFlagCookie(); + const expectedCookieTimeoutSeconds = 30; - expect(setCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, mockSessionId, { + expect(mockSetCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, mockSessionId, { maxAge: expectedCookieTimeoutSeconds, secure: true, httpOnly: true, @@ -64,16 +55,12 @@ describe("setSignOutFlagCookie", () => { }); it("should not set signout cookie if session id is missing", async () => { - mockGetCookie = () => { - return undefined; - }; - setCookie.mockClear(); - const warnMock = getLoggerWarnMock(); - warnMock.mockClear(); + mockGetCookie.mockReturnValue(undefined); await setSignOutFlagCookie(); - expect(setCookie).not.toHaveBeenCalled(); + expect(mockSetCookie).not.toHaveBeenCalled(); + const warnMock = getLoggerWarnMock(); expect(warnMock).toHaveBeenCalledWith("Session ID missing, skipping signout cookie"); }); }); From bc8454424cfebcf21e21d41e6af29a831fcdfdeb Mon Sep 17 00:00:00 2001 From: Liming Cheung <267123381+liming-cheung-nhs@users.noreply.github.com> Date: Tue, 5 May 2026 10:16:08 +0100 Subject: [PATCH 13/13] vale spelling --- scripts/config/vale/styles/config/vocabularies/words/accept.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 78bfe5403..9af6eacc8 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -3,6 +3,7 @@ bot (?i)CLI config Cyber +cybersecurity [Dd]ependabot [Dd]ev Dockerfile