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
5 changes: 1 addition & 4 deletions .github/workflows/cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .hygen/generate/resource/create/page-content.ejs.t
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -125,4 +126,4 @@ function Create() {
return <FormCreate />;
}

export default withPageRequiredAuth(Create);
export default withPageRequiredAuth(Create, { roles: [RoleEnum.ADMIN] });
3 changes: 2 additions & 1 deletion .hygen/generate/resource/edit/page-content.ejs.t
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -138,4 +139,4 @@ function Edit() {
return <FormEdit />;
}

export default withPageRequiredAuth(Edit);
export default withPageRequiredAuth(Edit, { roles: [RoleEnum.ADMIN] });
36 changes: 36 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,44 @@
## Table of Contents <!-- omit in toc -->

- [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`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
101 changes: 101 additions & 0 deletions playwright-tests/1-auth/auth-robustness.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 13 additions & 1 deletion playwright-tests/generators/run-crud.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions playwright.config.generators-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,4 @@ function CreateUser() {
return <FormCreateUser />;
}

export default withPageRequiredAuth(CreateUser);
export default withPageRequiredAuth(CreateUser, { roles: [RoleEnum.ADMIN] });
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,4 @@ function EditUser() {
);
}

export default withPageRequiredAuth(EditUser);
export default withPageRequiredAuth(EditUser, { roles: [RoleEnum.ADMIN] });
Loading
Loading