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
151 changes: 151 additions & 0 deletions SW.Bitween.Web/ClientApp/e2e/sign-out.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect, test, type Page } from "@playwright/test";
import { ADMIN_EMAIL, ADMIN_PASSWORD } from "./helpers";

/**
* Signing out, and the four ways it used to go wrong.
*
* The session lives in four places — a row in the database, the HttpOnly refresh
* cookie, the Jwt in localStorage, and React's own copy. Only the last one decides
* what you see, and it used to be the one thing a sign-out could fail to clear:
* `signOut` awaited the server first, so any failure threw before the session was
* ended and left the whole app on screen with the Jwt already deleted. Every test
* here is a way that could happen.
*/

const submitCredentials = async (page: Page) => {
await page.fill("#login-email", ADMIN_EMAIL);
await page.fill("#login-password", ADMIN_PASSWORD);
await page.getByRole("button", { name: /^Sign in$/ }).click();
};

async function signIn(page: Page) {
await page.goto("login");
await submitCredentials(page);
await page.waitForURL((u) => !u.pathname.endsWith("/login"), { timeout: 15000 });
}

const signOut = async (page: Page) => {
await page.getByRole("button", { name: "Account menu" }).click();
await page.getByRole("button", { name: /Sign out/ }).click();
};

/** The signed-in shell: present only while React holds a session. */
const shell = (page: Page) => page.getByRole("button", { name: "Account menu" });

test("the login page appears without waiting for the server", async ({ page }) => {
await signIn(page);
await page.route("**/api/accounts/logout", async (r) => {
await new Promise((res) => setTimeout(res, 3000));
await r.continue();
});
const started = Date.now();
await signOut(page);
// Held for 3s on purpose: the session ends locally first, so nothing waits on it.
await page.waitForURL(/\/login/, { timeout: 2500 });
expect(Date.now() - started).toBeLessThan(2500);
});

test("a refused sign-out still signs you out", async ({ page }) => {
await signIn(page);
await page.route("**/api/accounts/logout", (r) => r.fulfill({ status: 500, body: "boom" }));
await signOut(page);
await page.waitForURL(/\/login/, { timeout: 5000 });
expect(await page.evaluate(() => localStorage.getItem("access_token"))).toBeNull();
});

test("an unreachable backend still signs you out", async ({ page }) => {
await signIn(page);
await page.route("**/api/accounts/logout", (r) => r.abort("connectionrefused"));
await signOut(page);
await page.waitForURL(/\/login/, { timeout: 5000 });
});

test("signing out in one tab ends the session in the other", async ({ page, context }) => {
await signIn(page);
const other = await context.newPage();
await other.goto("https://localhost:7155/partners");
await other.waitForTimeout(2000);
expect(await shell(other).count()).toBe(1);

await signOut(page);
await page.waitForURL(/\/login/, { timeout: 5000 });
// Both credentials are shared by every tab, so the session really has ended here
// too — this tab just never used to find out until someone pressed refresh.
await other.waitForURL(/\/login/, { timeout: 8000 });
});

test("a sign-out that never answers does not block signing back in", async ({ page }) => {
await signIn(page);
await page.route("**/api/accounts/logout", () => {
/* never fulfilled: the request hangs rather than failing */
});
await signOut(page);
await page.waitForURL(/\/login/, { timeout: 5000 });

// A sign-in cancels the pending sign-out rather than waiting for it. Waiting was
// the obvious guard against the race in the next test, and would have deadlocked
// here for as long as the request hung.
await submitCredentials(page);
await page.waitForURL((u) => !u.pathname.endsWith("/login"), { timeout: 8000 });
expect(await shell(page).count()).toBe(1);
});

test("a slow sign-out response cannot wipe the session that replaced it", async ({ page }) => {
await signIn(page);
// The logout response carries `Clear-Site-Data: "cookies", "storage"`, which the
// browser applies to the whole origin whenever it lands — including over a newer
// sign-in. Cancelling the request means the response never arrives.
await page.route("**/api/accounts/logout", async (r) => {
await new Promise((res) => setTimeout(res, 4000));
await r.continue();
});
await signOut(page);
await page.waitForURL(/\/login/, { timeout: 5000 });
await submitCredentials(page);
await page.waitForURL((u) => !u.pathname.endsWith("/login"), { timeout: 10000 });

await page.waitForTimeout(5000); // outlast the delayed response
expect(page.url()).not.toContain("/login");
expect(await page.evaluate(() => localStorage.getItem("access_token"))).not.toBeNull();
await page.goto("partners");
await page.waitForTimeout(1500);
expect(await shell(page).count()).toBe(1);
});

test("a slow session read cannot flash the app back after a sign-out", async ({ page, context }) => {
await signIn(page);

// Held open so it lands after the sign-out below. It returns 200 — the Jwt went
// out before the sign-out and is still valid — so its result must be discarded on
// arrival rather than trusted, or the app appears again over a dead session.
await page.route("**/api/accounts/profile", async (r) => {
await new Promise((res) => setTimeout(res, 4000));
await r.continue();
});
const reloading = page.reload();
await page.waitForTimeout(800);

const other = await context.newPage();
await other.goto("https://localhost:7155/partners");
await other.waitForTimeout(1500);
await signOut(other);
await other.waitForURL(/\/login/, { timeout: 8000 });

let everSignedIn = false;
for (let i = 0; i < 60; i++) {
if (await shell(page).count()) everSignedIn = true;
await page.waitForTimeout(100);
}
await reloading.catch(() => {});
expect(everSignedIn).toBe(false);
expect(page.url()).toContain("/login");
});

test("a session that ended server-side does not need a refresh", async ({ page }) => {
await signIn(page);
// Exactly what a sign-out elsewhere leaves behind: no cookie, a useless Jwt.
await page.context().clearCookies();
await page.evaluate(() => localStorage.setItem("access_token", "dead"));
await page.getByRole("link", { name: /^Partners$/ }).click();
await page.waitForURL(/\/login/, { timeout: 10000 });
});
23 changes: 22 additions & 1 deletion SW.Bitween.Web/ClientApp/src/api/http/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,22 @@ import { ApiRequestError } from "../types";
export const API_BASE = "/api";

/** The Jwt is kept in localStorage; the refresh token is an HttpOnly cookie JS never sees. */
const TOKEN_KEY = "access_token";
export const TOKEN_KEY = "access_token";

/**
* Told when a request proves the session is over — the Jwt was refused and no
* refresh cookie was left to replace it.
*
* A callback rather than a hook because this is plain module code that cannot
* reach React state. `SessionProvider` registers itself on mount; with nothing
* registered, the `UNAUTHENTICATED` error below is all that happens, which is
* what used to leave a dead session rendering the entire app until somebody
* pressed refresh.
*/
let sessionEndedListener: (() => void) | null = null;
export const onSessionEnded = (listener: (() => void) | null): void => {
sessionEndedListener = listener;
};

export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
export const setToken = (jwt: string): void => localStorage.setItem(TOKEN_KEY, jwt);
Expand Down Expand Up @@ -88,6 +103,8 @@ export interface RequestOptions {
body?: unknown;
/** Internal: prevents the 401 → refresh → retry loop from recursing. */
_retried?: boolean;
/** Lets a caller cancel the request — only `logout` needs this, and says why. */
signal?: AbortSignal;
}

/**
Expand All @@ -111,12 +128,16 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: sendJson ? JSON.stringify(opts.body ?? {}) : undefined,
...(opts.signal ? { signal: opts.signal } : {}),
});

if (res.status === 401 && !opts._retried) {
const refreshed = await silentRefresh();
if (refreshed) return request<T>(path, { ...opts, _retried: true });
clearToken();
// Tell the app, not only the caller. Every page with a read in flight is about
// to render its own small error, and a page's error state cannot end a session.
sessionEndedListener?.();
throw new ApiRequestError("UNAUTHENTICATED", "Your session has ended. Please sign in again.");
}

Expand Down
42 changes: 39 additions & 3 deletions SW.Bitween.Web/ClientApp/src/api/http/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ApiRequestError, type Session, type User } from "../types";
import { getAppConfig } from "./appConfig";
import { clearToken, get, getToken, post, setToken } from "./request";
import { clearToken, get, getToken, post, request, setToken } from "./request";

/** GET /accounts/profile — camelCase ProfileModel. */
interface Profile {
Expand Down Expand Up @@ -42,6 +42,26 @@ const buildSession = (profile: Profile): Session => {

const loadSession = async (): Promise<Session> => buildSession(await get<Profile>("/accounts/profile"));

/**
* The sign-out request that may still be in flight, so a sign-in can cancel it.
*
* Its response carries `Clear-Site-Data: "cookies", "storage"`, which the browser
* applies to the whole origin the moment it arrives — so landing *after* a fresh
* sign-in, it wipes that sign-in's Jwt and refresh cookie and throws the user back
* to the page they just left. The two genuinely can overlap now that signing out
* no longer blocks the UI: the login page appears at once, and a password manager
* can fill and submit it before the logout lands.
*
* Waiting for the logout first was the obvious guard and the wrong one — a request
* that hangs rather than fails would block sign-in for as long as it hung. Nothing
* in the response is worth waiting for: it was already sent, so the server deletes
* the refresh token either way, and only the header we don't want is discarded.
*/
let logoutInFlight: AbortController | null = null;

/** A sign-in supersedes any sign-out still in flight. */
const abortPendingLogout = () => logoutInFlight?.abort();

export const sessionMethods = {
async getSession(): Promise<Session | null> {
// No stored Jwt → anonymous; don't probe the backend (an expired token still
Expand All @@ -57,6 +77,7 @@ export const sessionMethods = {
},

async login(email: string, password: string): Promise<Session> {
abortPendingLogout();
const { jwt } = await post<LoginResult>("/accounts/login", {
Username: email,
Password: password,
Expand All @@ -66,6 +87,7 @@ export const sessionMethods = {
},

async loginWithMicrosoft(): Promise<Session> {
abortPendingLogout();
const cfg = await getAppConfig();
if (!cfg.msalClientId)
throw new ApiRequestError("MS_NOT_CONFIGURED", "Microsoft sign-in isn't configured.");
Expand Down Expand Up @@ -94,10 +116,24 @@ export const sessionMethods = {
},

async logout(): Promise<void> {
// Cleared first and unconditionally. This used to run in a `finally`, which
// deleted the Jwt and then let the error through — the worst pairing, because
// the caller aborted before it could end the session and the app carried on
// rendering as if signed in. Removing the key here is also what wakes the
// other tabs (see the `storage` listener in SessionContext).
clearToken();
const controller = new AbortController();
logoutInFlight = controller;
try {
await post("/accounts/logout");
await request("/accounts/logout", { method: "POST", signal: controller.signal });
} catch {
// Swallowed on purpose: signing out must not depend on the server answering,
// and a sign-in aborting this is a normal outcome rather than a fault. Only
// the server can invalidate the refresh cookie — it is HttpOnly, so JS cannot
// touch it — so if this never lands the cookie outlives the sign-out. This
// browser has no Jwt to pair with it, and the next sign-in replaces it.
} finally {
clearToken();
if (logoutInFlight === controller) logoutInFlight = null;
}
},

Expand Down
1 change: 1 addition & 0 deletions SW.Bitween.Web/ClientApp/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export const api: ApiClient = httpClient;
export { getAppConfig, resetAppConfig } from "./http/appConfig";
export type { AppConfig } from "./http/appConfig";
export { referencesGlobal, referencesPartnerProp } from "./http/references";
export { TOKEN_KEY, onSessionEnded } from "./http/request";
export * from "./types";
Loading
Loading