diff --git a/src/app/_components/inactivity/InactivityDialog.test.tsx b/src/app/_components/inactivity/InactivityDialog.test.tsx
index 003ccb97..bca40433 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/app/our-policies/cookies-policy/CookiesTable.test.tsx b/src/app/our-policies/cookies-policy/CookiesTable.test.tsx
index 547edcc5..c4d594ef 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);
});
});
diff --git a/src/app/our-policies/cookies-policy/CookiesTable.tsx b/src/app/our-policies/cookies-policy/CookiesTable.tsx
index 39e345f2..bc1e318a 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 |
+
);
diff --git a/src/utils/auth/callbacks/get-token.test.ts b/src/utils/auth/callbacks/get-token.test.ts
index d25ef619..a37ab166 100644
--- a/src/utils/auth/callbacks/get-token.test.ts
+++ b/src/utils/auth/callbacks/get-token.test.ts
@@ -4,10 +4,14 @@ 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";
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";
jest.mock("@project/auth", () => ({
auth: jest.fn(),
@@ -20,6 +24,10 @@ 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 +63,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 +189,41 @@ describe("getToken", () => {
maxAgeInSeconds,
);
});
+
+ 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 { 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();
+ });
+
+ 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();
+ });
});
describe("when AUTH APIM is not available", () => {
@@ -196,6 +249,24 @@ describe("getToken", () => {
maxAgeInSeconds,
);
});
+
+ 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 { 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/callbacks/get-token.ts b/src/utils/auth/callbacks/get-token.ts
index a3adca09..41601067 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 { 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";
+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);
+
+ 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");
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/setSignOutFlagCookie.test.ts b/src/utils/auth/setSignOutFlagCookie.test.ts
new file mode 100644
index 00000000..b04494d2
--- /dev/null
+++ b/src/utils/auth/setSignOutFlagCookie.test.ts
@@ -0,0 +1,46 @@
+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();
+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((name) => {
+ if (name === SESSION_ID_COOKIE_NAME) {
+ return { value: mockSessionId };
+ }
+ return undefined;
+ }),
+ 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 the current session id", async () => {
+ await setSignOutFlagCookie();
+ const expectedCookieTimeoutSeconds = 30;
+ expect(setCookie).toHaveBeenCalledWith(SIGNOUT_FLAG_COOKIE_NAME, mockSessionId, {
+ maxAge: expectedCookieTimeoutSeconds,
+ 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 00000000..fa7c75c5
--- /dev/null
+++ b/src/utils/auth/setSignOutFlagCookie.ts
@@ -0,0 +1,23 @@
+"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";
+
+const setSignOutFlagCookie = async () => {
+ return requestScopedStorageWrapper(setSignOutFlagCookieAction);
+};
+
+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 ?? "";
+ cookieStore.set(SIGNOUT_FLAG_COOKIE_NAME, currentSessionId, {
+ secure: true,
+ httpOnly: true,
+ sameSite: "lax",
+ maxAge: SIGN_OUT_FLAG_COOKIE_MAX_AGE_SECONDS,
+ });
+};
+
+export default setSignOutFlagCookie;
diff --git a/src/utils/auth/user-logout.test.ts b/src/utils/auth/user-logout.test.ts
index cbaa85e5..b0d468c7 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 d3d97285..b8cf2fc2 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 2367a1ef..84936217 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";