Skip to main content

Authentication

PCMR.gr uses Better Auth v1.5.5 for all authentication. Better Auth runs inside NestJS (not Next.js), making NestJS the single source of truth for sessions.

Configuration (src/auth/auth.ts)

Why baseURL is the frontend URL

baseURL: process.env.BETTER_AUTH_URL; // must be the FRONTEND URL

Better Auth uses baseURL to construct links in verification emails (e.g., password reset, email verification). If set to the API URL, those links would point to the API instead of the frontend. Since the frontend proxies all /api/auth/* to NestJS, setting baseURL to the frontend URL works correctly.

BETTER_AUTH_URL must be set to the same frontend public URL in both the API and web containers.

Session cookies

Cookie config:

cookie: {
prefix: 'better-auth',
domain: process.env.COOKIE_DOMAIN || undefined,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
}

Cookie names:

  • HTTPS production: __Secure-better-auth.session_token
  • Local dev (HTTP): better-auth.session_token

COOKIE_DOMAIN: Must be empty string (not set) for local dev — browsers reject Domain=localhost. In production: .pcmr.gr (leading dot = all subdomains, so both pcmr.gr and staff.pcmr.gr share the session).

Custom user fields

user: {
additionalFields: {
mustChangePassword: { type: 'boolean', input: false },
firstName: { type: 'string', input: true },
lastName: { type: 'string', input: true },
},
}

mustChangePassword is input: false — it can never be set via the client API, only by server-side code (e.g., create-admin.js script).

databaseHooks.user.create.before

Before any user is created, name is computed:

  • For regular registration: firstName + " " + lastName
  • For Google OAuth (no firstName/lastName): splits the Google name field into parts

Domain policy (src/auth/utils/domain-policy.ts)

const STAFF_DOMAIN = 'ctsolutions.gr';
export function isStaffDomain(email: string): boolean { ... }

A before /sign-up/email hook blocks @ctsolutions.gr addresses from self-registering. Staff accounts are created by CLI only.

RolesGuard also enforces that admin and superadmin roles must have a @ctsolutions.gr email.


Plugins

admin()

Enables Better Auth's admin plugin — provides session revocation, user management API that AdminUsersService uses under the hood.

twoFactor()

TOTP-based 2FA. Required for all staff accounts as part of the first-login flow.


Auth Controller (src/auth/auth.controller.ts)

The controller is a single @All('*path') catch-all on api/auth/*:

  1. @SkipThrottle() — global 100 req/min limiter is bypassed (auth has its own Express-level rate limits)
  2. Converts the Express Request to a Web API Request object
  3. Calls auth.handler(webRequest)
  4. Converts the Web API Response back to the Express response
  5. Handles Set-Cookie headers via getSetCookie() (polyfill required for multi-value cookie support in Node < 21)
  6. Always sets Cache-Control: no-store on auth responses

Social Providers

Google OAuth is configured for customer accounts only. Staff must use email/password + TOTP — they cannot log in via Google.


First-Login Flow (Staff)

New staff accounts are created with mustChangePassword: true. The FirstLoginGuard enforces the following steps before the account can access the portal:

  1. Change temporary passwordmustChangePassword === trueMUST_CHANGE_PASSWORD error code
  2. Verify email!user.emailVerifiedEMAIL_NOT_VERIFIED error code
  3. Set up TOTP 2FA!user.twoFactorEnabledTOTP_SETUP_REQUIRED error code

The web app's staff first-login pages (/staff/first-login/*) handle each step in sequence.


Staff Account Creation

Staff accounts are never created via the UI. Only via the CLI script:

sudo docker exec -it <api_container> sh -c \
"node dist/scripts/create-admin.js [email protected] temppass123 FirstName LastName superadmin"

Valid roles for staff: admin, superadmin. Role user is for customers.


Problem: [email protected] has a bug where headers.set('set-cookie') overwrites previous values. When verifyTotp sets two cookies (session token + 2FA state), only the last one survives, leaving the user without a valid session.

Workaround: apps/web/src/app/api/2fa-verify/route.ts is not a standard proxy. After calling the Better Auth TOTP verify endpoint, it:

  1. Reads the response cookies
  2. Manually signs the session token using BETTER_AUTH_SECRET
  3. Sets the __Secure-better-auth.session_token cookie directly on the Next.js response

This means /api/2fa-verify must not be treated as a simple proxy — it has security-critical logic.


Server-Side Session Validation (Next.js)

apps/web/src/lib/auth-server.ts exports getServerSession():

const res = await fetch(`${API_URL}/api/auth/get-session`, {
headers: { cookie: cookieHeader, origin: BETTER_AUTH_URL },
cache: "no-store",
});

Uses the internal API_URL (not public NEXT_PUBLIC_API_URL) to avoid the public internet / Cloudflare round-trip from server components.


Rate Limits

EndpointLimitWindow
POST /api/auth/sign-in/email10 req15 min per IP
POST /api/auth/sign-up/email5 req1 hr per IP
All other /api/*100 req1 min per IP