Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 _))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
})();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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("/");
})();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string | undefined> {
const cookies = await context.cookies();
return cookies.find((cookie) => cookie.name === refreshTokenCookieName)?.value;
}

async function setRefreshTokenCookie(context: BrowserContext, value: string, domain: string): Promise<void> {
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
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading