diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index e377771da..3cdfc1381 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -44,12 +44,6 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) await next(context); - // Ensure all 401 responses have an unauthorized reason header for consistent frontend handling - if (context.Response.StatusCode == StatusCodes.Status401Unauthorized && - !context.Response.Headers.ContainsKey(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey)) - { - context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = nameof(UnauthorizedReason.SessionNotFound); - } if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _)) { diff --git a/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts index 2ad5ade26..3a556401b 100644 --- a/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts @@ -278,11 +278,11 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible(); await expect(page.getByText("The page you are looking for does not exist or was moved.")).toBeVisible(); - await expect(page.getByRole("link", { name: "Go to home" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Go to home" })).toBeVisible(); })(); await step("Click Go to home button on 404 page & verify navigation to home")(async () => { - await page.getByRole("link", { name: "Go to home" }).click(); + await page.getByRole("button", { name: "Go to home" }).click(); await expect(page).toHaveURL("/"); })(); diff --git a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts index 037be7e50..d1464ef99 100644 --- a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts @@ -210,7 +210,7 @@ test.describe("@smoke", () => { await expect(page.getByRole("heading", { name: "Access denied" })).toBeVisible(); await expect(page.getByText("You do not have permission to access this page.")).toBeVisible(); - await expect(page.getByRole("link", { name: "Go to home" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Go to home" })).toBeVisible(); })(); await step("Navigate to back-office as Member & verify access denied page displays")(async () => { @@ -221,7 +221,7 @@ test.describe("@smoke", () => { })(); await step("Click Go to home on access denied page & verify navigation to home")(async () => { - await page.getByRole("link", { name: "Go to home" }).click(); + await page.getByRole("button", { name: "Go to home" }).click(); await expect(page).toHaveURL("/"); })(); diff --git a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts index 3962eaf47..dc9fcd983 100644 --- a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts @@ -1,10 +1,45 @@ -import type { Browser } from "@playwright/test"; +import type { Browser, BrowserContext, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; +const accessTokenCookieName = "__Host_Access_Token"; +const refreshTokenCookieName = "__Host_Refresh_Token"; + +async function deleteAccessTokenCookie(page: Page): Promise { + const cookies = await page.context().cookies(); + const accessToken = cookies.find((cookie) => cookie.name === accessTokenCookieName); + if (accessToken) { + await page.context().clearCookies({ name: accessTokenCookieName }); + } +} + +async function getRefreshTokenCookie(context: BrowserContext): Promise { + const cookies = await context.cookies(); + return cookies.find((cookie) => cookie.name === refreshTokenCookieName)?.value; +} + +async function setRefreshTokenCookie(context: BrowserContext, value: string, domain: string): Promise { + await context.addCookies([ + { + name: refreshTokenCookieName, + value, + domain, + path: "/", + secure: true, + httpOnly: true, + sameSite: "Strict" + } + ]); +} + +function getDomainFromPage(page: Page): string { + const url = new URL(page.url()); + return url.hostname; +} + test.describe("@smoke", () => { /** * SESSION MANAGEMENT WORKFLOW @@ -117,3 +152,189 @@ test.describe("@smoke", () => { })(); }); }); + +test.describe("@comprehensive", () => { + /** + * SESSION REVOKED ERROR PAGE + * + * Tests that when a session is revoked from another browser, the revoked browser + * is redirected to the session-revoked error page with the correct message. + * + * Flow: + * 1. User logs in to browser A + * 2. User logs in to browser B (same account, different session) + * 3. Browser B revokes the session from browser A + * 4. Browser A deletes its access token and navigates (triggering refresh) + * 5. Browser A is redirected to /error?error=session-revoked + */ + test("should redirect to session-revoked error page when session is revoked from another browser", async ({ + page + }, testInfo) => { + test.skip( + testInfo.project.name === "webkit", + "WebKit __Host_ cookie handling prevents refresh token from being sent after access token manipulation" + ); + + const context = createTestContext(page); + const owner = testUser(); + const browser = page.context().browser() as Browser; + + await step("Sign up user in primary browser & verify dashboard")(async () => { + await completeSignupFlow(page, expect, owner, context); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + const secondContext = await browser.newContext(); + const secondPage = await secondContext.newPage(); + createTestContext(secondPage); + + await step("Login same user in secondary browser & verify dashboard")(async () => { + await secondPage.goto("/login"); + await expect(secondPage.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + await secondPage.getByRole("textbox", { name: "Email" }).fill(owner.email); + await secondPage.getByRole("button", { name: "Continue" }).click(); + await expect(secondPage).toHaveURL("/login/verify"); + await secondPage.keyboard.type(getVerificationCode()); + + await expect(secondPage).toHaveURL("/admin"); + await expect(secondPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Revoke primary session from secondary browser & verify success")(async () => { + const secondPageContext = createTestContext(secondPage); + const secondSessionsDialog = secondPage.getByRole("dialog", { name: "Sessions" }); + + await secondPage.getByRole("button", { name: "User profile menu" }).click(); + await expect(secondPage.getByRole("menu")).toBeVisible(); + await secondPage.getByRole("menuitem", { name: "Sessions" }).click(); + + await expect(secondSessionsDialog).toBeVisible(); + + const sessionCards = secondSessionsDialog.locator("div.rounded-lg.border").filter({ hasText: "IP:" }); + await expect(sessionCards).toHaveCount(2); + + const otherSessionCard = sessionCards.filter({ hasNotText: "Current session" }).first(); + await otherSessionCard.getByRole("button", { name: "Revoke" }).click(); + + const revokeDialog = secondPage.getByRole("alertdialog", { name: "Revoke session" }); + await expect(revokeDialog).toBeVisible(); + await revokeDialog.getByRole("button", { name: "Revoke", exact: true }).click(); + + await expectToastMessage(secondPageContext, "Session revoked successfully"); + await secondSessionsDialog.locator("svg.cursor-pointer").click(); + })(); + + await step("Navigate in revoked session & verify session-revoked error page")(async () => { + await deleteAccessTokenCookie(page); + context.monitoring.expectedStatusCodes.push(401); + + await page.getByRole("link", { name: "Users", exact: true }).click(); + + await expect(page).toHaveURL(/\/error\?.*error=session-revoked/); + await expect(page.getByRole("heading", { name: "Session ended" })).toBeVisible(); + await expect(page.getByText("Your session was ended from another device.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + })(); + + await step("Click login on session-revoked page & verify login page")(async () => { + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + await secondContext.close(); + }); + + /** + * REPLAY ATTACK DETECTION ERROR PAGE + * + * Tests that when a refresh token is "stolen" and used from another browser, + * both browsers are eventually redirected to the replay-attack error page. + * + * Flow: + * 1. User logs in to browser A (refresh token version 1) + * 2. Copy refresh token from browser A to browser B + * 3. Browser B uses stolen token twice (version becomes 3, grace period ends) + * 4. Browser A tries to refresh (replay detected, session revoked) + * 5. Browser B tries to refresh (session already revoked) + * 6. Both browsers see the replay-attack error page + */ + test("should redirect to replay-attack error page when refresh token replay is detected", async ({ + page + }, testInfo) => { + test.skip( + testInfo.project.name === "webkit", + "WebKit __Host_ cookie handling prevents programmatic cookie manipulation required for replay attack simulation" + ); + + const context = createTestContext(page); + const owner = testUser(); + const browser = page.context().browser() as Browser; + + await step("Sign up user & verify dashboard")(async () => { + await completeSignupFlow(page, expect, owner, context); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + const stolenRefreshToken = await getRefreshTokenCookie(page.context()); + expect(stolenRefreshToken).toBeDefined(); + + const secondContext = await browser.newContext(); + const secondPage = await secondContext.newPage(); + createTestContext(secondPage); + + await step("Inject stolen refresh token into attacker browser & verify token set")(async () => { + const domain = getDomainFromPage(page); + await setRefreshTokenCookie(secondContext, stolenRefreshToken as string, domain); + })(); + + await step("Use stolen token twice in attacker browser & verify access granted")(async () => { + const secondPageContext = createTestContext(secondPage); + secondPageContext.monitoring.expectedStatusCodes.push(401); + + await secondPage.goto("/admin"); + await expect(secondPage).toHaveURL("/admin"); + await expect(secondPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + await deleteAccessTokenCookie(secondPage); + await secondPage.getByRole("link", { name: "Users", exact: true }).click(); + await expect(secondPage).toHaveURL("/admin/users"); + await expect(secondPage.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + + await step("Navigate in victim browser after replay & verify replay-attack error page")(async () => { + await deleteAccessTokenCookie(page); + context.monitoring.expectedStatusCodes.push(401); + + await page.getByRole("link", { name: "Users", exact: true }).click(); + + await expect(page).toHaveURL(/\/error\?.*error=replay-attack/); + await expect(page.getByRole("heading", { name: "Security alert" })).toBeVisible(); + await expect(page.getByText("We detected suspicious activity on your account.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); + })(); + + await step("Navigate in attacker browser after replay & verify replay-attack error page")(async () => { + const secondPageContext = createTestContext(secondPage); + secondPageContext.monitoring.expectedStatusCodes.push(401); + await deleteAccessTokenCookie(secondPage); + + await secondPage.getByLabel("Main navigation").getByRole("link", { name: "Home" }).click(); + + await expect(secondPage).toHaveURL(/\/error\?.*error=replay-attack/); + await expect(secondPage.getByRole("heading", { name: "Security alert" })).toBeVisible(); + await expect(secondPage.getByText("We detected suspicious activity on your account.")).toBeVisible(); + })(); + + await step("Click login on replay-attack page & verify login page")(async () => { + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + await secondContext.close(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts index b025d04da..03cf97bb2 100644 --- a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts @@ -436,7 +436,7 @@ test.describe("@smoke", () => { await step("Navigate to users page & complete profile setup")(async () => { // Navigate to users page where member has access and profile dialog appears - await page.getByRole("link", { name: "Go to home" }).click(); + await page.getByRole("button", { name: "Go to home" }).click(); await expect(page).toHaveURL("/"); await page.goto("/admin/users");