--- type: api title: Auth Endpoints description: Login, refresh, logout, public self-registration, CSRF, and first-admin registration. tags: [api, auth, login, register, refresh, csrf] timestamp: 2026-07-21T18:28:00Z --- # Endpoints | Method | Path | Auth | CSRF | Rate limited | Source | |--------|-----------------------------------|--------|-----------------------------------------------|--------------|--------| | `POST` | `/api/v1/auth/register` | `@Public()` | Enforced — caller must first call `GET /api/v1/auth/csrf` (the SPA's `AuthService.ensureCsrf()` does this automatically). | Yes — `RegistrationRateLimitService` per IP | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/login` | `@Public()` | Enforced — same pattern as `register`. CSRF skip was removed in this change. | Yes — `LoginBackoffService` per IP + username | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/refresh` | Public route, requires a valid refresh cookie. | Skipped (the request is a `POST` carrying an HttpOnly cookie, not a state-changing user action). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/logout` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` | | `GET` | `/api/v1/auth/csrf` | `@Public()` | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/register-first-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`). | Yes | `backend/src/modules/users/users.controller.ts` | # Public player self-registration — `POST /api/v1/auth/register` New endpoint, gated by the `registrationsEnabled` setting (`backend/src/config/env.schema.ts` → `SETTINGS_KEYS.REGISTRATIONS_ENABLED`, default `"false"`). | Field | Type | Constraints | |-------------------|--------|------------------------------------------------------------------------------| | `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` | | `password` | string | 1–256 chars; also passes `validatePassword` (Argon2 policy). | | `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED` on `passwordConfirm`). | Validated by `backend/src/modules/auth/dto/auth.dto.ts` → `RegisterDtoSchema`. ## Successful response (201) ```json { "accessToken": "", "expiresIn": 900, "user": { "id": "", "username": "player1", "role": "player" } } ``` The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, session.refreshToken, config)`. The new `user` row has `role='player'` and `status='enabled'`. ## Error envelope | HTTP | `code` | When | |------|-------------------------|---------------------------------------------------------------------------------| | 400 | `VALIDATION_FAILED` | zod body validation failed. | | 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password. | | 403 | `REGISTRATIONS_DISABLED`| `setting.registrationsEnabled !== 'true'`. | | 409 | `USERNAME_TAKEN` | A user with that username already exists. | | 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. | ## Wiring * `AuthService.registerPlayer(dto, ip)` runs inside a single `DataSource.transaction`: 1. `RegistrationRateLimitService.isAllowed(ip)` check. 2. `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')` check. 3. Username uniqueness lookup → `USERNAME_TAKEN` on collision. 4. Argon2id hash + insert `UserEntity({ role: 'player', status: 'enabled' })`. 5. `RegistrationRateLimitService.record(ip)`. 6. `mintSession(manager, user)` → access JWT + refresh row. # Frontend consumer `frontend/src/app/core/services/auth.service.ts` exposes `login()` and `register()`. Both: 1. Call `ensureCsrf()` → `GET /api/v1/auth/csrf` to materialise the `csrf` cookie if it isn't set yet (failures are swallowed because the `CsrfMiddleware` will mint the cookie on the next response anyway). 2. `POST` with `{ withCredentials: true, observe: 'response' }`. 3. Return a discriminated union: * `{ ok: true, accessToken, expiresIn, user }` on success. * `{ ok: false, status, code, message }` on failure, built by the private `mapAuthError()` helper from the standard [error envelope](/api/rest-overview.md). The `LandingComponent` (`frontend/src/app/features/landing/landing.component.ts`) consumes both methods and maps the `code` to user-facing copy via `buildLoginFailureMessage` (see [Landing Page](/guides/landing-page.md)). # See also - [REST API Overview](/api/rest-overview.md) - [Setup Endpoint](/api/setup.md) - [Landing Page Guide](/guides/landing-page.md)