diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 7dee476b..6bd21695 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -69,9 +69,6 @@ jobs:
env:
NESTJS_DIR: ${{ github.workspace }}/nestjs-boilerplate
run: npm run test:e2e:generators:crud
- - name: Capture backend logs
- if: always()
- run: docker compose -f nestjs-boilerplate/docker-compose.generators-relational.test.yaml -p tests-gen-frontend logs > ${{ runner.temp }}/backend.log 2>&1 || true
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
@@ -82,5 +79,5 @@ jobs:
if: ${{ !cancelled() }}
with:
name: crud-backend-log
- path: ${{ runner.temp }}/backend.log
+ path: test-results/backend.log
retention-days: 30
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index be30bcdf..6c66385d 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -21,7 +21,7 @@ jobs:
# print output of the command to file and store it as artifact
run: cd backend && docker compose -f docker-compose.document.yaml up > ${{ runner.temp }}/backend.log 2>&1 &
- run: cd backend && sed -i 's/\r//g' wait-for-it.sh
- - run: cd backend && ./wait-for-it.sh localhost:3001 -- echo "Backend is up"
+ - run: cd backend && ./wait-for-it.sh localhost:3001 -t 600 --strict -- echo "Backend is up"
- name: Check out frontend
uses: actions/checkout@v6
diff --git a/.hygen/generate/resource/create/page-content.ejs.t b/.hygen/generate/resource/create/page-content.ejs.t
index b524bdb5..fb08463b 100644
--- a/.hygen/generate/resource/create/page-content.ejs.t
+++ b/.hygen/generate/resource/create/page-content.ejs.t
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { useForm, FormProvider, useFormState } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
+import { RoleEnum } from "@/services/api/types/role";
import withPageRequiredAuth from "@/services/auth/with-page-required-auth";
import { useSnackbar } from "@/hooks/use-snackbar";
import Link from "@/components/link";
@@ -125,4 +126,4 @@ function Create() {
return ;
}
-export default withPageRequiredAuth(Create);
+export default withPageRequiredAuth(Create, { roles: [RoleEnum.ADMIN] });
diff --git a/.hygen/generate/resource/edit/page-content.ejs.t b/.hygen/generate/resource/edit/page-content.ejs.t
index 13d6ddd9..f2d4ebb0 100644
--- a/.hygen/generate/resource/edit/page-content.ejs.t
+++ b/.hygen/generate/resource/edit/page-content.ejs.t
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { useForm, FormProvider, useFormState } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
+import { RoleEnum } from "@/services/api/types/role";
import withPageRequiredAuth from "@/services/auth/with-page-required-auth";
import { useSnackbar } from "@/hooks/use-snackbar";
import Link from "@/components/link";
@@ -138,4 +139,4 @@ function Edit() {
return ;
}
-export default withPageRequiredAuth(Edit);
+export default withPageRequiredAuth(Edit, { roles: [RoleEnum.ADMIN] });
diff --git a/docs/auth.md b/docs/auth.md
index 46120182..361eeaa4 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -3,8 +3,44 @@
## Table of Contents
- [Auth](#auth)
+ - [Token storage](#token-storage)
+ - [Token refresh](#token-refresh)
+ - [Cross-tab behavior](#cross-tab-behavior)
+ - [Logout](#logout)
- [Auth via Google](#auth-via-google)
+## Token storage
+
+After sign-in the API returns `token`, `refreshToken`, and `tokenExpires`. They are stored as JSON in a single cookie (`auth-token-data`, see `src/services/auth/auth-tokens-info.ts`) written with:
+
+| Attribute | Value | Why |
+| ---------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
+| `path` | `/` | available on every route |
+| `expires` | 30 days (`AUTH_TOKEN_COOKIE_EXPIRES_DAYS`) | matches the backend `AUTH_REFRESH_TOKEN_EXPIRES_IN`, so the session survives browser restarts |
+| `sameSite` | `lax` | not sent on cross-site subrequests |
+| `secure` | set on `https:` pages | never transmitted over plain HTTP in production |
+
+The cookie is readable by JavaScript by design — requests attach the token via the `Authorization` header (`src/services/api/use-fetch.ts`), and the API never reads cookies, which also makes CSRF a non-issue for this setup.
+
+A cookie (rather than `localStorage`) is used so auth can later be shared between subdomains: `localStorage` is strictly per-origin, while a cookie can be scoped to a parent domain. To enable sharing, add a `domain` option (e.g. `.example.com`) to `cookieOptions()` — it applies to both `set` and `remove`.
+
+## Token refresh
+
+`useFetch` (`src/services/api/use-fetch.ts`) refreshes tokens in two ways:
+
+- **Proactively** — when `tokenExpires` is less than 60 seconds away, a refresh runs before the request goes out.
+- **Reactively** — when a request still returns `401` (revoked session, clock skew, token expired mid-flight), the wrapper refreshes once and retries the request once.
+
+Concurrent refreshes are de-duplicated within a tab and serialized across tabs with the Web Locks API, so two tabs cannot both consume the same single-use refresh token. A refresh that fails with a network error keeps the current tokens (nobody is logged out for being offline); a `401`/`422` from the refresh endpoint clears them and broadcasts a logout to all tabs.
+
+## Cross-tab behavior
+
+The cookie is shared between same-site tabs, so refreshed tokens are picked up automatically. Login and logout additionally broadcast events over a `BroadcastChannel` (`src/services/auth/auth-events.ts`): other tabs update their user state and clear the TanStack Query cache without a reload.
+
+## Logout
+
+`logOut` calls `POST /v1/auth/logout` and then always clears the cookie and the query cache — even when the request fails (offline, server down) — so the user is never stuck logged in locally, and the next account on the machine cannot see the previous user's cached data.
+
## Auth via Google
1. You need a `Client Id`. You can find these pieces of information by going to the [Developer Console](https://console.cloud.google.com/), clicking your project (if you don't have it create project here https://console.cloud.google.com/projectcreate) -> `APIs & services` -> `credentials`.
diff --git a/package.json b/package.json
index 1412bc43..b721772d 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"dev": "next dev --turbopack",
"build": "next build",
"analyze": "next experimental-analyze",
- "build:e2e": "cp -n example.env.local .env.local && next build",
+ "build:e2e": "[ -f .env.local ] || cp example.env.local .env.local && next build",
"start": "next start",
"lint": "eslint .",
"prepare": "is-ci || husky",
diff --git a/playwright-tests/1-auth/auth-robustness.spec.ts b/playwright-tests/1-auth/auth-robustness.spec.ts
new file mode 100644
index 00000000..66a442ef
--- /dev/null
+++ b/playwright-tests/1-auth/auth-robustness.spec.ts
@@ -0,0 +1,101 @@
+import { test, expect } from "@playwright/test";
+import { faker } from "@faker-js/faker";
+import { apiCreateNewUser } from "../helpers/api-requests.js";
+import { login } from "../helpers/login.js";
+
+let email: string;
+let password: string;
+
+test.beforeEach(async () => {
+ email = faker.internet.email({
+ provider: "example.com",
+ });
+ password = faker.internet.password();
+ await apiCreateNewUser(
+ email,
+ password,
+ faker.person.firstName(),
+ faker.person.lastName()
+ );
+});
+
+test.describe("Auth robustness", () => {
+ test("should render logged out when the auth cookie is corrupted", async ({
+ page,
+ }) => {
+ await page.context().addCookies([
+ {
+ name: "auth-token-data",
+ value: "not-valid-json",
+ url: "http://localhost:3000",
+ },
+ ]);
+
+ await page.goto("/");
+ await expect(page.getByTestId("home-title")).toBeVisible();
+ await expect(page.getByTestId("profile-menu-item")).not.toBeVisible();
+ });
+
+ test("should log out locally even when the logout request fails", async ({
+ page,
+ }) => {
+ await login(email, password, page);
+
+ await page.route("**/auth/logout", (route) => route.abort());
+
+ await page.getByTestId("profile-menu-item").click();
+ await page.getByTestId("logout-menu-item").click();
+
+ await expect(page.getByTestId("profile-menu-item")).not.toBeVisible();
+ const cookies = await page.context().cookies();
+ expect(
+ cookies.find((cookie) => cookie.name === "auth-token-data")
+ ).toBeUndefined();
+ });
+
+ test("should recover from an invalid access token via refresh and retry", async ({
+ page,
+ }) => {
+ await login(email, password, page);
+
+ const cookies = await page.context().cookies();
+ const authCookie = cookies.find(
+ (cookie) => cookie.name === "auth-token-data"
+ );
+ expect(authCookie).toBeDefined();
+ const tokens = JSON.parse(decodeURIComponent(authCookie!.value));
+
+ // Keep the valid refresh token but corrupt the access token, with a
+ // future tokenExpires so only the reactive 401 path can recover.
+ await page.context().addCookies([
+ {
+ name: "auth-token-data",
+ value: encodeURIComponent(
+ JSON.stringify({
+ token: "broken-token",
+ refreshToken: tokens.refreshToken,
+ tokenExpires: Date.now() + 15 * 60 * 1000,
+ })
+ ),
+ url: "http://localhost:3000",
+ },
+ ]);
+
+ await page.goto("/profile");
+ await expect(page.getByTestId("user-email")).toBeVisible();
+ });
+
+ test("should log out other tabs", async ({ page, context }) => {
+ await login(email, password, page);
+
+ const secondPage = await context.newPage();
+ await secondPage.goto("/");
+ await expect(secondPage.getByTestId("profile-menu-item")).toBeVisible();
+
+ await page.getByTestId("profile-menu-item").click();
+ await page.getByTestId("logout-menu-item").click();
+ await expect(page.getByTestId("profile-menu-item")).not.toBeVisible();
+
+ await expect(secondPage.getByTestId("profile-menu-item")).not.toBeVisible();
+ });
+});
diff --git a/playwright-tests/generators/run-crud.sh b/playwright-tests/generators/run-crud.sh
index bef0c32a..195d1324 100755
--- a/playwright-tests/generators/run-crud.sh
+++ b/playwright-tests/generators/run-crud.sh
@@ -10,7 +10,8 @@
# admin UI against the real backend).
# 4. Tears everything down on EXIT — bounded by path on both repos.
#
-# Defaults: NESTJS_DIR=../nestjs-boilerplate (override via env if elsewhere).
+# Defaults: NESTJS_DIR=../nestjs-boilerplate (override via env if elsewhere),
+# BACKEND_LOG_PATH=test-results/backend.log (container logs, always written).
#
# Usage: bash playwright-tests/generators/run-crud.sh
@@ -23,6 +24,9 @@ NESTJS_DIR="${NESTJS_DIR:-$(cd "$REPO_ROOT/../nestjs-boilerplate" 2>/dev/null &&
COMPOSE_FILE="docker-compose.generators-relational.test.yaml"
COMPOSE_PROJECT="tests-gen-frontend"
HOST_API_PORT="${HOST_API_PORT:-3001}"
+# Inside Playwright's (gitignored, run-scoped) output dir — it is cleared at the
+# start of a run, and this is written at the end, so the two never collide.
+BACKEND_LOG_PATH="${BACKEND_LOG_PATH:-$REPO_ROOT/test-results/backend.log}"
# ── Pre-flight ───────────────────────────────────────────────────────────────
@@ -63,6 +67,14 @@ cleanup() {
echo ""
echo "▶ Cleanup: tearing down backend stack and reverting generated source on both sides…"
+ # Dump container logs BEFORE the stack is destroyed. `down -v` takes the
+ # containers and their logs with it, so anything running after this script —
+ # a CI capture step included — finds nothing left to read.
+ mkdir -p "$(dirname "$BACKEND_LOG_PATH")" 2>/dev/null || true
+ (cd "$NESTJS_DIR" && docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" logs --no-color) \
+ > "$BACKEND_LOG_PATH" 2>&1 || true
+ echo " backend logs → $BACKEND_LOG_PATH"
+
(cd "$NESTJS_DIR" && docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" down -v) 2>/dev/null || true
# React side
diff --git a/playwright.config.generators-crud.ts b/playwright.config.generators-crud.ts
index 2cc2b9a9..7b804b5a 100644
--- a/playwright.config.generators-crud.ts
+++ b/playwright.config.generators-crud.ts
@@ -17,12 +17,15 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
- reporter: "list",
+ // "list" alone produces no report and no traces, so a CI failure here leaves
+ // nothing to diagnose from — keep the console output and add both.
+ reporter: [["list"], ["html", { open: "never" }]],
timeout: 2 * 60 * 1000,
expect: { timeout: 20 * 1000 },
use: {
baseURL: "http://localhost:3000",
- trace: "on-first-retry",
+ // retries are 0 here, so "on-first-retry" never captures anything.
+ trace: "retain-on-failure",
},
projects: [
{
@@ -31,10 +34,13 @@ export default defineConfig({
},
],
webServer: {
- // Match the project's existing local-dev Playwright wiring (next dev with turbopack).
- // `next build` via `build:e2e` is avoided because the script's BSD `cp -n` exits 1
- // on macOS when `.env.local` already exists, breaking the && chain.
- command: "npm run dev",
+ // CI builds for production, as playwright.config.ts does: `next dev` compiles
+ // each route on first visit, which pushes page loads past the 20s expect
+ // timeout on a cold CI runner. (`build:e2e` used to be unusable here because
+ // its BSD `cp -n` exited 1 when `.env.local` existed; that is fixed.)
+ command: process.env.CI
+ ? "npm run build:e2e && npm run start"
+ : "npm run dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
timeout: 5 * 60 * 1000,
diff --git a/src/app/[language]/admin-panel/users/create/page-content.tsx b/src/app/[language]/admin-panel/users/create/page-content.tsx
index ad0e6a15..6fa6a67a 100644
--- a/src/app/[language]/admin-panel/users/create/page-content.tsx
+++ b/src/app/[language]/admin-panel/users/create/page-content.tsx
@@ -238,4 +238,4 @@ function CreateUser() {
return ;
}
-export default withPageRequiredAuth(CreateUser);
+export default withPageRequiredAuth(CreateUser, { roles: [RoleEnum.ADMIN] });
diff --git a/src/app/[language]/admin-panel/users/edit/[id]/page-content.tsx b/src/app/[language]/admin-panel/users/edit/[id]/page-content.tsx
index 8ce4e83c..9d992609 100644
--- a/src/app/[language]/admin-panel/users/edit/[id]/page-content.tsx
+++ b/src/app/[language]/admin-panel/users/edit/[id]/page-content.tsx
@@ -359,4 +359,4 @@ function EditUser() {
);
}
-export default withPageRequiredAuth(EditUser);
+export default withPageRequiredAuth(EditUser, { roles: [RoleEnum.ADMIN] });
diff --git a/src/services/api/use-fetch.ts b/src/services/api/use-fetch.ts
index 7b48829d..623ec6db 100644
--- a/src/services/api/use-fetch.ts
+++ b/src/services/api/use-fetch.ts
@@ -3,36 +3,177 @@
import { useCallback } from "react";
import { AUTH_REFRESH_URL } from "./config";
import { FetchInputType, FetchInitType } from "./types/fetch-params";
+import HTTP_CODES_ENUM from "./types/http-codes";
+import type { Tokens } from "./types/tokens";
import useLanguage from "../i18n/use-language";
import { getTokensInfo, setTokensInfo } from "../auth/auth-tokens-info";
+import { emitAuthEvent } from "../auth/auth-events";
+import type { TokensInfo } from "../auth/auth-context";
-let refreshPromise: Promise | null = null;
+const TOKEN_EXPIRES_SKEW_MS = 60000;
+const REFRESH_TIMEOUT_MS = 10000;
+const REFRESH_LOCK_WAIT_MS = 15000;
-async function refreshTokens(): Promise {
+type RefreshResult = "refreshed" | "unauthorized" | "error";
+
+type StoredTokensChange = "unchanged" | "rotated" | "other-account";
+
+let refreshPromise: Promise | null = null;
+
+function isTokenExpiringSoon(tokenExpires: Tokens["tokenExpires"]): boolean {
+ return Boolean(
+ tokenExpires && tokenExpires - TOKEN_EXPIRES_SKEW_MS <= Date.now()
+ );
+}
+
+function getTokenAccountId(token: Tokens["token"]): string | null {
+ const payload = token?.split(".")[1];
+
+ if (!payload) return null;
+
+ try {
+ const claims: unknown = JSON.parse(
+ atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
+ );
+
+ if (typeof claims !== "object" || claims === null) return null;
+
+ const id = (claims as { id?: unknown }).id;
+
+ return id === null || id === undefined ? null : String(id);
+ } catch {
+ return null;
+ }
+}
+
+function classifyStoredTokens(before: TokensInfo): StoredTokensChange {
+ const after = getTokensInfo();
+
+ if (!after?.refreshToken || after.refreshToken === before?.refreshToken) {
+ return "unchanged";
+ }
+
+ const beforeAccountId = getTokenAccountId(before?.token);
+ const afterAccountId = getTokenAccountId(after.token);
+
+ if (beforeAccountId === null || afterAccountId === null) {
+ return "rotated";
+ }
+
+ return beforeAccountId === afterAccountId ? "rotated" : "other-account";
+}
+
+async function requestNewTokens(): Promise {
+ const tokens = getTokensInfo();
+
+ if (!tokens?.refreshToken) {
+ return "unauthorized";
+ }
+
+ let response: Response;
+
+ try {
+ response = await fetch(AUTH_REFRESH_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${tokens.refreshToken}`,
+ },
+
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
+ });
+ } catch {
+ return "error";
+ }
+
+ if (
+ response.status === HTTP_CODES_ENUM.UNAUTHORIZED ||
+ response.status === HTTP_CODES_ENUM.UNPROCESSABLE_ENTITY
+ ) {
+ const change = classifyStoredTokens(tokens);
+
+ if (change === "rotated") {
+ return "refreshed";
+ }
+
+ if (change === "other-account") {
+ return "error";
+ }
+
+ setTokensInfo(null);
+ emitAuthEvent({ type: "logout" });
+
+ return "unauthorized";
+ }
+
+ try {
+ const newTokens = await response.json();
+
+ if (newTokens.token) {
+ setTokensInfo({
+ token: newTokens.token,
+ refreshToken: newTokens.refreshToken,
+ tokenExpires: newTokens.tokenExpires,
+ });
+
+ return "refreshed";
+ }
+ } catch {
+ // Malformed response body — treated like a network failure below.
+ }
+
+ return "error";
+}
+
+async function refreshTokensExclusive(
+ tokensBefore: TokensInfo
+): Promise {
+ const change = classifyStoredTokens(tokensBefore);
+
+ if (change === "rotated") {
+ return "refreshed";
+ }
+
+ if (change === "other-account") {
+ return "error";
+ }
+
+ return requestNewTokens();
+}
+
+async function refreshTokens(): Promise {
if (refreshPromise) return refreshPromise;
+ const tokensBefore = getTokensInfo();
+
refreshPromise = (async () => {
try {
- const tokens = getTokensInfo();
- const response = await fetch(AUTH_REFRESH_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${tokens?.refreshToken}`,
- },
- });
+ // The Web Lock serializes refreshes across same-origin tabs so two
+ // tabs cannot both consume the single-use refresh token; browsers
+ // without navigator.locks still get the per-tab refreshPromise dedupe.
+ if (typeof navigator !== "undefined" && navigator.locks) {
+ let result: RefreshResult = "error";
- const newTokens = await response.json();
+ try {
+ await navigator.locks.request(
+ "auth-token-refresh",
+ { signal: AbortSignal.timeout(REFRESH_LOCK_WAIT_MS) },
+ async () => {
+ result = await refreshTokensExclusive(tokensBefore);
+ }
+ );
+ } catch {
+ return classifyStoredTokens(tokensBefore) === "rotated"
+ ? "refreshed"
+ : "error";
+ }
- if (newTokens.token) {
- setTokensInfo({
- token: newTokens.token,
- refreshToken: newTokens.refreshToken,
- tokenExpires: newTokens.tokenExpires,
- });
+ return result;
}
+
+ return await refreshTokensExclusive(tokensBefore);
} catch {
- // Refresh failed — callers will proceed with current token
+ return "error";
} finally {
refreshPromise = null;
}
@@ -46,44 +187,52 @@ function useFetch() {
return useCallback(
async (input: FetchInputType, init?: FetchInitType) => {
- const tokens = getTokensInfo();
+ const doFetch = () => {
+ const tokens = getTokensInfo();
- let headers: HeadersInit = {
- "x-custom-lang": language,
- };
-
- if (!(init?.body instanceof FormData)) {
- headers = {
- ...headers,
- "Content-Type": "application/json",
+ let headers: HeadersInit = {
+ "x-custom-lang": language,
};
- }
- if (tokens?.token) {
- headers = {
- ...headers,
- Authorization: `Bearer ${tokens.token}`,
- };
- }
+ if (!(init?.body instanceof FormData)) {
+ headers = {
+ ...headers,
+ "Content-Type": "application/json",
+ };
+ }
- if (tokens?.tokenExpires && tokens.tokenExpires - 60000 <= Date.now()) {
- await refreshTokens();
- const refreshedTokens = getTokensInfo();
- if (refreshedTokens?.token) {
+ if (tokens?.token) {
headers = {
...headers,
- Authorization: `Bearer ${refreshedTokens.token}`,
+ Authorization: `Bearer ${tokens.token}`,
};
}
+
+ return fetch(input, {
+ ...init,
+ headers: {
+ ...headers,
+ ...init?.headers,
+ },
+ });
+ };
+
+ if (isTokenExpiringSoon(getTokensInfo()?.tokenExpires)) {
+ await refreshTokens();
}
- return fetch(input, {
- ...init,
- headers: {
- ...headers,
- ...init?.headers,
- },
- });
+ const response = await doFetch();
+
+ const isRetryable =
+ response.status === HTTP_CODES_ENUM.UNAUTHORIZED &&
+ input !== AUTH_REFRESH_URL &&
+ Boolean(getTokensInfo()?.refreshToken);
+
+ if (isRetryable && (await refreshTokens()) === "refreshed") {
+ return doFetch();
+ }
+
+ return response;
},
[language]
);
diff --git a/src/services/auth/auth-events.ts b/src/services/auth/auth-events.ts
new file mode 100644
index 00000000..7a4ee2d9
--- /dev/null
+++ b/src/services/auth/auth-events.ts
@@ -0,0 +1,36 @@
+"use client";
+
+export type AuthEvent = { type: "login" } | { type: "logout" };
+
+type AuthEventListener = (event: AuthEvent) => void;
+
+const listeners = new Set();
+
+// Cookies fire no "storage" events, so other tabs cannot observe auth changes
+// through the shared cookie alone — a BroadcastChannel carries login/logout
+// notifications between tabs instead.
+const channel =
+ typeof window !== "undefined" && "BroadcastChannel" in window
+ ? new BroadcastChannel("auth")
+ : null;
+
+if (channel) {
+ channel.onmessage = (event: MessageEvent) => {
+ listeners.forEach((listener) => listener(event.data));
+ };
+}
+
+export function emitAuthEvent(event: AuthEvent) {
+ // BroadcastChannel does not deliver messages to the emitting context, so
+ // local listeners are notified explicitly.
+ listeners.forEach((listener) => listener(event));
+ channel?.postMessage(event);
+}
+
+export function onAuthEvent(listener: AuthEventListener) {
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ };
+}
diff --git a/src/services/auth/auth-provider.tsx b/src/services/auth/auth-provider.tsx
index 392513f8..4775ad70 100644
--- a/src/services/auth/auth-provider.tsx
+++ b/src/services/auth/auth-provider.tsx
@@ -21,6 +21,8 @@ import {
getTokensInfo,
setTokensInfo as setTokensInfoToStorage,
} from "./auth-tokens-info";
+import { emitAuthEvent, onAuthEvent } from "./auth-events";
+import queryClient from "@/services/react-query/query-client";
function AuthProvider(props: PropsWithChildren) {
const [isLoaded, setIsLoaded] = useState(false);
@@ -30,20 +32,28 @@ function AuthProvider(props: PropsWithChildren) {
const setTokensInfo = useCallback((tokensInfo: TokensInfo) => {
setTokensInfoToStorage(tokensInfo);
- if (!tokensInfo) {
+ if (tokensInfo) {
+ emitAuthEvent({ type: "login" });
+ } else {
setUser(null);
+ emitAuthEvent({ type: "logout" });
}
}, []);
const logOut = useCallback(async () => {
const tokens = getTokensInfo();
- if (tokens?.token) {
- await fetchBase(AUTH_LOGOUT_URL, {
- method: "POST",
- });
+ try {
+ if (tokens?.token) {
+ await fetchBase(AUTH_LOGOUT_URL, {
+ method: "POST",
+ });
+ }
+ } finally {
+ // Local auth state must be cleared even when the logout request fails
+ // (offline, server down) — otherwise the user stays logged in.
+ setTokensInfo(null);
}
- setTokensInfo(null);
}, [setTokensInfo, fetchBase]);
const loadData = useCallback(async () => {
@@ -72,6 +82,19 @@ function AuthProvider(props: PropsWithChildren) {
loadData();
}, [loadData]);
+ useEffect(() => {
+ return onAuthEvent((event) => {
+ if (event.type === "logout") {
+ setUser(null);
+ // Cached queries belong to the previous user; keeping them would
+ // show that user's data to the next account on this machine.
+ queryClient.clear();
+ } else {
+ loadData();
+ }
+ });
+ }, [loadData]);
+
const contextValue = useMemo(
() => ({
isLoaded,
diff --git a/src/services/auth/auth-tokens-info.ts b/src/services/auth/auth-tokens-info.ts
index 1345ec68..c5417e23 100644
--- a/src/services/auth/auth-tokens-info.ts
+++ b/src/services/auth/auth-tokens-info.ts
@@ -1,15 +1,29 @@
-import { TokensInfo } from "./auth-context";
import Cookies from "js-cookie";
-import { AUTH_TOKEN_KEY } from "./config";
+import type { TokensInfo } from "./auth-context";
+import { AUTH_TOKEN_COOKIE_EXPIRES_DAYS, AUTH_TOKEN_KEY } from "./config";
+
+function cookieOptions(): Cookies.CookieAttributes {
+ return {
+ path: "/",
+ expires: AUTH_TOKEN_COOKIE_EXPIRES_DAYS,
+ sameSite: "lax",
+ secure:
+ typeof window !== "undefined" && window.location.protocol === "https:",
+ };
+}
export function getTokensInfo() {
- return JSON.parse(Cookies.get(AUTH_TOKEN_KEY) ?? "null") as TokensInfo;
+ try {
+ return JSON.parse(Cookies.get(AUTH_TOKEN_KEY) ?? "null") as TokensInfo;
+ } catch {
+ return null;
+ }
}
export function setTokensInfo(tokens: TokensInfo) {
if (tokens) {
- Cookies.set(AUTH_TOKEN_KEY, JSON.stringify(tokens));
+ Cookies.set(AUTH_TOKEN_KEY, JSON.stringify(tokens), cookieOptions());
} else {
- Cookies.remove(AUTH_TOKEN_KEY);
+ Cookies.remove(AUTH_TOKEN_KEY, cookieOptions());
}
}
diff --git a/src/services/auth/config.ts b/src/services/auth/config.ts
index 24b97b45..d57ea535 100644
--- a/src/services/auth/config.ts
+++ b/src/services/auth/config.ts
@@ -2,3 +2,5 @@ export const IS_SIGN_UP_ENABLED =
process.env.NEXT_PUBLIC_IS_SIGN_UP_ENABLED === "true";
export const AUTH_TOKEN_KEY = "auth-token-data";
+
+export const AUTH_TOKEN_COOKIE_EXPIRES_DAYS = 30;
diff --git a/src/services/auth/get-safe-return-to.ts b/src/services/auth/get-safe-return-to.ts
new file mode 100644
index 00000000..6ec22ac8
--- /dev/null
+++ b/src/services/auth/get-safe-return-to.ts
@@ -0,0 +1,43 @@
+/**
+ * Resolves a `returnTo` query parameter to a path guaranteed to stay on the
+ * current origin, falling back when it does not.
+ *
+ * Prefix checks are not enough. Browsers strip ASCII tab, LF and CR while
+ * parsing a URL, so `?returnTo=/%09/evil.com` arrives here as "/\t/evil.com":
+ * it starts with "/", does not start with "//" or "/\", and still resolves to
+ * "//evil.com" once the router parses it. Parsing the value with the same URL
+ * parser the router uses and comparing origins is what actually closes the
+ * open redirect.
+ */
+function getSafeReturnTo(
+ requestedReturnTo: string | null | undefined,
+ fallback: string
+): string {
+ if (
+ requestedReturnTo === null ||
+ requestedReturnTo === undefined ||
+ requestedReturnTo === ""
+ ) {
+ return fallback;
+ }
+
+ try {
+ const origin = window.location.origin;
+ const url = new URL(requestedReturnTo, origin);
+
+ // Covers "//host", "/\host", "https://host", "javascript:" (origin
+ // "null") and any control-character variant of them.
+ if (url.origin !== origin) {
+ return fallback;
+ }
+
+ // Hand the router a path rather than the raw value, so a same-origin
+ // absolute URL is normalized instead of being re-parsed downstream.
+ return `${url.pathname}${url.search}${url.hash}`;
+ } catch {
+ // Unparseable value, or no `window` — never navigate on either.
+ return fallback;
+ }
+}
+
+export default getSafeReturnTo;
diff --git a/src/services/auth/with-page-required-guest.tsx b/src/services/auth/with-page-required-guest.tsx
index 5858478d..2bbf5437 100644
--- a/src/services/auth/with-page-required-guest.tsx
+++ b/src/services/auth/with-page-required-guest.tsx
@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation";
import useAuth from "./use-auth";
import React, { FunctionComponent, useEffect } from "react";
import useLanguage from "@/services/i18n/use-language";
+import getSafeReturnTo from "./get-safe-return-to";
type PropsType = {
params?: { [key: string]: string | string[] | undefined };
@@ -20,7 +21,12 @@ function withPageRequiredGuest(Component: FunctionComponent) {
if (!user || !isLoaded) return;
const params = new URLSearchParams(window.location.search);
- const returnTo = params.get("returnTo") ?? `/${language}`;
+ // Accept only same-origin paths, or an attacker-supplied ?returnTo
+ // redirects the user off-site after login.
+ const returnTo = getSafeReturnTo(
+ params.get("returnTo"),
+ `/${language}`
+ );
router.replace(returnTo);
};
diff --git a/src/services/i18n/client.ts b/src/services/i18n/client.ts
index 0bd879e3..09ecf0a2 100644
--- a/src/services/i18n/client.ts
+++ b/src/services/i18n/client.ts
@@ -32,11 +32,29 @@ i18next
preload: runsOnServerSide ? languages : [],
});
+let hasHydrated = false;
+const suspendedNamespaces = new Set();
+
+function canSuspendOn(namespace: string) {
+ if (!hasHydrated) return true;
+
+ const key = `${i18next.resolvedLanguage}|${namespace}`;
+
+ if (suspendedNamespaces.has(key)) return false;
+
+ suspendedNamespaces.add(key);
+
+ return true;
+}
+
export function useTranslation(namespace: string, options?: object) {
const language = useLanguage();
const { language: cookies } = useStoreLanguage();
const { setLanguage: setCookie } = useStoreLanguageActions();
- const originalInstance = useTranslationOriginal(namespace, options);
+ const originalInstance = useTranslationOriginal(namespace, {
+ ...options,
+ useSuspense: runsOnServerSide || canSuspendOn(namespace),
+ });
const { i18n } = originalInstance;
if (runsOnServerSide && language && i18n.resolvedLanguage !== language) {
i18n.changeLanguage(language);
@@ -44,6 +62,10 @@ export function useTranslation(namespace: string, options?: object) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [activeLanguage, setActiveLanguage] = useState(i18n.resolvedLanguage);
// eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ hasHydrated = true;
+ }, []);
+ // eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (activeLanguage === i18n.resolvedLanguage) return;
setActiveLanguage(i18n.resolvedLanguage);
diff --git a/tsconfig.json b/tsconfig.json
index 96c82b35..b18f5150 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -31,5 +31,11 @@
"./.next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules", "**/*.stories.ts", "**/*.stories.tsx"]
+ "exclude": [
+ "node_modules",
+ "**/*.stories.ts",
+ "**/*.stories.tsx",
+ "nestjs-boilerplate",
+ "backend"
+ ]
}