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" + ] }