Skip to content

Commit 8970f14

Browse files
authored
Add E2E tests for session security error pages and fix 401 response handling (#830)
### Summary & Motivation Add end-to-end tests for two session security scenarios: session revocation detection and replay attack detection. These tests verify that users are properly redirected to context-specific error pages with appropriate messaging when their sessions are compromised. The tests simulate real-world security scenarios: - Session revoked from another browser shows "Session ended" message - Replay attack detection (stolen refresh token) shows "Security alert" message Note: The security tests are skipped on Safari because WebKit does not allow programmatic manipulation of `__Host_` prefixed cookies, which is required to simulate these attack scenarios. The tests run on Chromium and Firefox. Also remove obsolete fallback logic in `AuthenticationCookieMiddleware` that added `SessionNotFound` header to all 401 responses. This fallback was originally added when the backend did not consistently set the `x-unauthorized-reason` header, but is no longer needed since commit 0b7fdc2 ensures all 401 responses include the header. - Add comprehensive E2E tests for session-revoked and replay-attack error pages with helper functions for cookie manipulation - Remove obsolete SessionNotFound fallback from AppGateway middleware - Update E2E tests to use button role instead of link role for error page navigation after commit bfb77c5 ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents b4eb972 + fd10fb6 commit 8970f14

File tree

5 files changed

+227
-12
lines changed

5 files changed

+227
-12
lines changed

application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,6 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
4444

4545
await next(context);
4646

47-
// Ensure all 401 responses have an unauthorized reason header for consistent frontend handling
48-
if (context.Response.StatusCode == StatusCodes.Status401Unauthorized &&
49-
!context.Response.Headers.ContainsKey(AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey))
50-
{
51-
context.Response.Headers[AuthenticationTokenHttpKeys.UnauthorizedReasonHeaderKey] = nameof(UnauthorizedReason.SessionNotFound);
52-
}
5347

5448
if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _))
5549
{

application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,11 @@ test.describe("@comprehensive", () => {
278278

279279
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
280280
await expect(page.getByText("The page you are looking for does not exist or was moved.")).toBeVisible();
281-
await expect(page.getByRole("link", { name: "Go to home" })).toBeVisible();
281+
await expect(page.getByRole("button", { name: "Go to home" })).toBeVisible();
282282
})();
283283

284284
await step("Click Go to home button on 404 page & verify navigation to home")(async () => {
285-
await page.getByRole("link", { name: "Go to home" }).click();
285+
await page.getByRole("button", { name: "Go to home" }).click();
286286

287287
await expect(page).toHaveURL("/");
288288
})();

application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ test.describe("@smoke", () => {
210210

211211
await expect(page.getByRole("heading", { name: "Access denied" })).toBeVisible();
212212
await expect(page.getByText("You do not have permission to access this page.")).toBeVisible();
213-
await expect(page.getByRole("link", { name: "Go to home" })).toBeVisible();
213+
await expect(page.getByRole("button", { name: "Go to home" })).toBeVisible();
214214
})();
215215

216216
await step("Navigate to back-office as Member & verify access denied page displays")(async () => {
@@ -221,7 +221,7 @@ test.describe("@smoke", () => {
221221
})();
222222

223223
await step("Click Go to home on access denied page & verify navigation to home")(async () => {
224-
await page.getByRole("link", { name: "Go to home" }).click();
224+
await page.getByRole("button", { name: "Go to home" }).click();
225225

226226
await expect(page).toHaveURL("/");
227227
})();

application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,45 @@
1-
import type { Browser } from "@playwright/test";
1+
import type { Browser, BrowserContext, Page } from "@playwright/test";
22
import { expect } from "@playwright/test";
33
import { test } from "@shared/e2e/fixtures/page-auth";
44
import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions";
55
import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data";
66
import { step } from "@shared/e2e/utils/test-step-wrapper";
77

8+
const accessTokenCookieName = "__Host_Access_Token";
9+
const refreshTokenCookieName = "__Host_Refresh_Token";
10+
11+
async function deleteAccessTokenCookie(page: Page): Promise<void> {
12+
const cookies = await page.context().cookies();
13+
const accessToken = cookies.find((cookie) => cookie.name === accessTokenCookieName);
14+
if (accessToken) {
15+
await page.context().clearCookies({ name: accessTokenCookieName });
16+
}
17+
}
18+
19+
async function getRefreshTokenCookie(context: BrowserContext): Promise<string | undefined> {
20+
const cookies = await context.cookies();
21+
return cookies.find((cookie) => cookie.name === refreshTokenCookieName)?.value;
22+
}
23+
24+
async function setRefreshTokenCookie(context: BrowserContext, value: string, domain: string): Promise<void> {
25+
await context.addCookies([
26+
{
27+
name: refreshTokenCookieName,
28+
value,
29+
domain,
30+
path: "/",
31+
secure: true,
32+
httpOnly: true,
33+
sameSite: "Strict"
34+
}
35+
]);
36+
}
37+
38+
function getDomainFromPage(page: Page): string {
39+
const url = new URL(page.url());
40+
return url.hostname;
41+
}
42+
843
test.describe("@smoke", () => {
944
/**
1045
* SESSION MANAGEMENT WORKFLOW
@@ -117,3 +152,189 @@ test.describe("@smoke", () => {
117152
})();
118153
});
119154
});
155+
156+
test.describe("@comprehensive", () => {
157+
/**
158+
* SESSION REVOKED ERROR PAGE
159+
*
160+
* Tests that when a session is revoked from another browser, the revoked browser
161+
* is redirected to the session-revoked error page with the correct message.
162+
*
163+
* Flow:
164+
* 1. User logs in to browser A
165+
* 2. User logs in to browser B (same account, different session)
166+
* 3. Browser B revokes the session from browser A
167+
* 4. Browser A deletes its access token and navigates (triggering refresh)
168+
* 5. Browser A is redirected to /error?error=session-revoked
169+
*/
170+
test("should redirect to session-revoked error page when session is revoked from another browser", async ({
171+
page
172+
}, testInfo) => {
173+
test.skip(
174+
testInfo.project.name === "webkit",
175+
"WebKit __Host_ cookie handling prevents refresh token from being sent after access token manipulation"
176+
);
177+
178+
const context = createTestContext(page);
179+
const owner = testUser();
180+
const browser = page.context().browser() as Browser;
181+
182+
await step("Sign up user in primary browser & verify dashboard")(async () => {
183+
await completeSignupFlow(page, expect, owner, context);
184+
await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible();
185+
})();
186+
187+
const secondContext = await browser.newContext();
188+
const secondPage = await secondContext.newPage();
189+
createTestContext(secondPage);
190+
191+
await step("Login same user in secondary browser & verify dashboard")(async () => {
192+
await secondPage.goto("/login");
193+
await expect(secondPage.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible();
194+
195+
await secondPage.getByRole("textbox", { name: "Email" }).fill(owner.email);
196+
await secondPage.getByRole("button", { name: "Continue" }).click();
197+
await expect(secondPage).toHaveURL("/login/verify");
198+
await secondPage.keyboard.type(getVerificationCode());
199+
200+
await expect(secondPage).toHaveURL("/admin");
201+
await expect(secondPage.getByRole("heading", { name: "Welcome home" })).toBeVisible();
202+
})();
203+
204+
await step("Revoke primary session from secondary browser & verify success")(async () => {
205+
const secondPageContext = createTestContext(secondPage);
206+
const secondSessionsDialog = secondPage.getByRole("dialog", { name: "Sessions" });
207+
208+
await secondPage.getByRole("button", { name: "User profile menu" }).click();
209+
await expect(secondPage.getByRole("menu")).toBeVisible();
210+
await secondPage.getByRole("menuitem", { name: "Sessions" }).click();
211+
212+
await expect(secondSessionsDialog).toBeVisible();
213+
214+
const sessionCards = secondSessionsDialog.locator("div.rounded-lg.border").filter({ hasText: "IP:" });
215+
await expect(sessionCards).toHaveCount(2);
216+
217+
const otherSessionCard = sessionCards.filter({ hasNotText: "Current session" }).first();
218+
await otherSessionCard.getByRole("button", { name: "Revoke" }).click();
219+
220+
const revokeDialog = secondPage.getByRole("alertdialog", { name: "Revoke session" });
221+
await expect(revokeDialog).toBeVisible();
222+
await revokeDialog.getByRole("button", { name: "Revoke", exact: true }).click();
223+
224+
await expectToastMessage(secondPageContext, "Session revoked successfully");
225+
await secondSessionsDialog.locator("svg.cursor-pointer").click();
226+
})();
227+
228+
await step("Navigate in revoked session & verify session-revoked error page")(async () => {
229+
await deleteAccessTokenCookie(page);
230+
context.monitoring.expectedStatusCodes.push(401);
231+
232+
await page.getByRole("link", { name: "Users", exact: true }).click();
233+
234+
await expect(page).toHaveURL(/\/error\?.*error=session-revoked/);
235+
await expect(page.getByRole("heading", { name: "Session ended" })).toBeVisible();
236+
await expect(page.getByText("Your session was ended from another device.")).toBeVisible();
237+
await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
238+
})();
239+
240+
await step("Click login on session-revoked page & verify login page")(async () => {
241+
await page.getByRole("button", { name: "Log in" }).click();
242+
243+
await expect(page).toHaveURL(/\/login/);
244+
await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible();
245+
})();
246+
247+
await secondContext.close();
248+
});
249+
250+
/**
251+
* REPLAY ATTACK DETECTION ERROR PAGE
252+
*
253+
* Tests that when a refresh token is "stolen" and used from another browser,
254+
* both browsers are eventually redirected to the replay-attack error page.
255+
*
256+
* Flow:
257+
* 1. User logs in to browser A (refresh token version 1)
258+
* 2. Copy refresh token from browser A to browser B
259+
* 3. Browser B uses stolen token twice (version becomes 3, grace period ends)
260+
* 4. Browser A tries to refresh (replay detected, session revoked)
261+
* 5. Browser B tries to refresh (session already revoked)
262+
* 6. Both browsers see the replay-attack error page
263+
*/
264+
test("should redirect to replay-attack error page when refresh token replay is detected", async ({
265+
page
266+
}, testInfo) => {
267+
test.skip(
268+
testInfo.project.name === "webkit",
269+
"WebKit __Host_ cookie handling prevents programmatic cookie manipulation required for replay attack simulation"
270+
);
271+
272+
const context = createTestContext(page);
273+
const owner = testUser();
274+
const browser = page.context().browser() as Browser;
275+
276+
await step("Sign up user & verify dashboard")(async () => {
277+
await completeSignupFlow(page, expect, owner, context);
278+
await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible();
279+
})();
280+
281+
const stolenRefreshToken = await getRefreshTokenCookie(page.context());
282+
expect(stolenRefreshToken).toBeDefined();
283+
284+
const secondContext = await browser.newContext();
285+
const secondPage = await secondContext.newPage();
286+
createTestContext(secondPage);
287+
288+
await step("Inject stolen refresh token into attacker browser & verify token set")(async () => {
289+
const domain = getDomainFromPage(page);
290+
await setRefreshTokenCookie(secondContext, stolenRefreshToken as string, domain);
291+
})();
292+
293+
await step("Use stolen token twice in attacker browser & verify access granted")(async () => {
294+
const secondPageContext = createTestContext(secondPage);
295+
secondPageContext.monitoring.expectedStatusCodes.push(401);
296+
297+
await secondPage.goto("/admin");
298+
await expect(secondPage).toHaveURL("/admin");
299+
await expect(secondPage.getByRole("heading", { name: "Welcome home" })).toBeVisible();
300+
301+
await deleteAccessTokenCookie(secondPage);
302+
await secondPage.getByRole("link", { name: "Users", exact: true }).click();
303+
await expect(secondPage).toHaveURL("/admin/users");
304+
await expect(secondPage.getByRole("heading", { name: "Users" })).toBeVisible();
305+
})();
306+
307+
await step("Navigate in victim browser after replay & verify replay-attack error page")(async () => {
308+
await deleteAccessTokenCookie(page);
309+
context.monitoring.expectedStatusCodes.push(401);
310+
311+
await page.getByRole("link", { name: "Users", exact: true }).click();
312+
313+
await expect(page).toHaveURL(/\/error\?.*error=replay-attack/);
314+
await expect(page.getByRole("heading", { name: "Security alert" })).toBeVisible();
315+
await expect(page.getByText("We detected suspicious activity on your account.")).toBeVisible();
316+
await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
317+
})();
318+
319+
await step("Navigate in attacker browser after replay & verify replay-attack error page")(async () => {
320+
const secondPageContext = createTestContext(secondPage);
321+
secondPageContext.monitoring.expectedStatusCodes.push(401);
322+
await deleteAccessTokenCookie(secondPage);
323+
324+
await secondPage.getByLabel("Main navigation").getByRole("link", { name: "Home" }).click();
325+
326+
await expect(secondPage).toHaveURL(/\/error\?.*error=replay-attack/);
327+
await expect(secondPage.getByRole("heading", { name: "Security alert" })).toBeVisible();
328+
await expect(secondPage.getByText("We detected suspicious activity on your account.")).toBeVisible();
329+
})();
330+
331+
await step("Click login on replay-attack page & verify login page")(async () => {
332+
await page.getByRole("button", { name: "Log in" }).click();
333+
334+
await expect(page).toHaveURL(/\/login/);
335+
await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible();
336+
})();
337+
338+
await secondContext.close();
339+
});
340+
});

application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ test.describe("@smoke", () => {
436436

437437
await step("Navigate to users page & complete profile setup")(async () => {
438438
// Navigate to users page where member has access and profile dialog appears
439-
await page.getByRole("link", { name: "Go to home" }).click();
439+
await page.getByRole("button", { name: "Go to home" }).click();
440440
await expect(page).toHaveURL("/");
441441

442442
await page.goto("/admin/users");

0 commit comments

Comments
 (0)