diff --git a/AUTH_RUNTIME.md b/AUTH_RUNTIME.md new file mode 100644 index 0000000..f1e3bf2 --- /dev/null +++ b/AUTH_RUNTIME.md @@ -0,0 +1,60 @@ +# Runtime Keycloak Auth + +This repository now uses Keycloak-based authentication for the runtime application only (`client/` and `server/`). + +## Required environment variables + +### Frontend (`client/.env`) + +- `VITE_API_URL` (example: `http://localhost:3000`) +- `VITE_KEYCLOAK_URL` (example: `https://sso.greact.ru`) +- `VITE_KEYCLOAK_REALM` (example: `toir`) +- `VITE_KEYCLOAK_CLIENT_ID` (example: `toir-frontend`) + +The frontend fails fast at startup if any required auth/env variable is missing. + +### Backend (`server/.env`) + +- `PORT` (example: `3000`) +- `DATABASE_URL` +- `CORS_ALLOWED_ORIGINS` (comma-separated list) +- `KEYCLOAK_ISSUER_URL` (example: `https://sso.greact.ru/realms/toir`) +- `KEYCLOAK_AUDIENCE` (example: `toir-backend`) +- `KEYCLOAK_JWKS_URL` (optional) + +Backend validates required env vars at startup and fails fast if missing. + +## Frontend auth flow + +- The SPA initializes Keycloak before rendering. +- Authentication is redirect-based Keycloak login only (no custom username/password form). +- Authorization Code + PKCE (`S256`) is used. +- Access token is attached to every API request via `Authorization: Bearer `. +- `checkError` behavior: + - `401`: force re-authentication. + - `403`: keep session and surface access denied to React Admin. +- Token refresh is concurrency-safe: parallel requests share one in-flight refresh operation. + +## Backend JWT validation and RBAC + +- JWTs are verified against: + - issuer: `KEYCLOAK_ISSUER_URL` + - audience: `KEYCLOAK_AUDIENCE` +- JWKS resolution priority: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery (`/.well-known/openid-configuration`) + 3. fallback `${issuer}/protocol/openid-connect/certs` +- RBAC source is only `realm_access.roles`. + +Role policy applied to CRUD endpoints: + +- `GET`: `viewer`, `editor`, `admin` +- `POST`, `PATCH` (and `PUT` if added): `editor`, `admin` +- `DELETE`: `admin` + +Public route: + +- `GET /health` + +All other existing API routes require authentication. + diff --git a/auth/backend-auth-rules.md b/auth/backend-auth-rules.md new file mode 100644 index 0000000..00d34e9 --- /dev/null +++ b/auth/backend-auth-rules.md @@ -0,0 +1,120 @@ +# Backend Auth Rules + +This document defines mandatory backend authentication and authorization behavior for generated applications. + +--- + +# Generated Backend Auth Surface + +The generator must create explicit auth infrastructure in the generated NestJS backend. + +At minimum generate: + +- `server/src/auth/auth.module.ts` +- JWT auth guard +- roles guard +- `@Public()` +- `@Roles()` +- typed authenticated principal interface +- typed environment validation in `server/src/config/` + +The generator must not describe backend auth as an external manual integration step. + +--- + +# Standards-Based JWT Verification + +The generated backend must validate JWTs against Keycloak using standards-based libraries. + +Rules: + +1. Verify **issuer** +2. Verify **audience** +3. Verify signature via **JWKS** +4. Do **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect` + +The default library rule for this repository is: + +- **`jose`** for JWT and JWKS verification + +--- + +# JWT Verification Contract + +The generated backend must verify tokens with: + +- `KEYCLOAK_ISSUER_URL` +- `KEYCLOAK_AUDIENCE` + +JWKS resolution priority must be exactly: + +1. explicit `KEYCLOAK_JWKS_URL` +2. OIDC discovery +3. fallback `${issuer}/protocol/openid-connect/certs` + +The generator must encode this priority explicitly. + +--- + +# Role Extraction + +Authorization roles must be extracted **only** from: + +- `realm_access.roles` + +The generator must not mix in: + +- resource roles +- custom frontend-only permissions +- undocumented claim fallbacks + +`realm_access.roles` is the single RBAC source for this repository. + +--- + +# Default RBAC Policy + +Apply these RBAC defaults to generated CRUD controllers: + +- `GET`: `viewer`, `editor`, `admin` +- `POST`: `editor`, `admin` +- `PATCH`: `editor`, `admin` +- `PUT`: `editor`, `admin` +- `DELETE`: `admin` + +`GET /health` must remain public and must use the generated `@Public()` mechanism. + +All other generated CRUD routes must be protected by default. + +--- + +# Typed Principal + +The generated backend must attach a typed authenticated principal to the request context. + +At minimum, the generated principal type must be able to represent: + +- `sub` +- user identity fields when present +- `roles` +- raw claims payload + +This principal type is required so guards, controllers, and tests share one consistent contract. + +--- + +# Backend Environment Contract + +The generated backend env contract must include: + +- `PORT` +- `DATABASE_URL` +- `CORS_ALLOWED_ORIGINS` +- `KEYCLOAK_ISSUER_URL` +- `KEYCLOAK_AUDIENCE` +- `KEYCLOAK_JWKS_URL` (optional) + +The generated backend config must **fail fast** if required auth variables are missing. + +The generated backend must not silently fall back to production auth settings in code. + diff --git a/auth/frontend-auth-rules.md b/auth/frontend-auth-rules.md new file mode 100644 index 0000000..394d44d --- /dev/null +++ b/auth/frontend-auth-rules.md @@ -0,0 +1,134 @@ +# Frontend Auth Rules + +This document defines mandatory frontend authentication behavior for generated applications. + +--- + +# Generated Files + +The generator must create at minimum: + +- `client/src/config/env.ts` +- `client/src/auth/keycloak.ts` +- `client/src/auth/authProvider.ts` +- `client/src/App.tsx` +- `client/src/main.tsx` +- `client/src/dataProvider.ts` +- `client/.env.example` + +`App.tsx`, `main.tsx`, and `dataProvider.ts` must be generated in an auth-aware form. Auth must not be bolted on later. + +--- + +# Login Model + +The generated frontend must use: + +- **Keycloak JS** +- **Authorization Code Flow + PKCE** +- **PKCE method `S256`** +- **redirect-based login only** + +The generator must **not** create a custom in-app username/password login form. + +The generated SPA must initialize Keycloak **before rendering** the application. The app must not operate anonymously once auth is enabled. + +--- + +# React Admin Integration + +The generated frontend must use a React Admin **`authProvider`** and connect it at the `Admin` root. + +Rules: + +1. `authProvider` is mandatory. +2. The generated `Admin` root must enforce authenticated operation. +3. Auth bootstrap must happen before rendering `Admin`. + +The generated frontend must not rely on anonymous access with later lazy auth attachment. + +--- + +# Shared Request Seam + +The generated frontend must use the shared request seam in `client/src/dataProvider.ts` as the single place where access tokens are attached. + +Rules: + +1. All backend requests must carry `Authorization: Bearer `. +2. This must cover all React Admin calls, including: + - list + - get one + - get many + - get many reference + - create + - update + - delete +3. Reference/resource lookups must flow through the same authenticated request seam. + +The generator must not scatter token attachment across resource components. + +--- + +# Error Semantics + +The generated `authProvider.checkError` must distinguish authentication failures from authorization failures: + +- `401` -> force logout / re-authentication +- `403` -> do **not** re-authenticate; surface access denied / permission error to React Admin + +The generator must not treat `401` and `403` as equivalent. + +--- + +# Token Refresh + +The generated frontend must refresh tokens before protected API calls when needed. + +Refresh behavior must be **concurrency-safe**: + +- use one shared in-flight refresh operation +- parallel requests must wait for the same refresh promise +- do not trigger multiple parallel refresh requests for the same expiry window + +The generator must explicitly describe or implement the shared in-flight refresh pattern. + +--- + +# Browser Storage Rules + +The generated frontend must **not** store access tokens or refresh tokens in: + +- `localStorage` +- `sessionStorage` + +In-memory handling via Keycloak JS behavior is the default rule for this repository. + +--- + +# Frontend Environment Contract + +The generated frontend env contract must include: + +- `VITE_API_URL` +- `VITE_KEYCLOAK_URL` +- `VITE_KEYCLOAK_REALM` +- `VITE_KEYCLOAK_CLIENT_ID` + +The generated frontend config module must **fail fast** if required auth variables are missing. + +The generated frontend must not silently fall back to production auth settings in code. + +--- + +# Default Values for Examples + +`client/.env.example` must use these repository defaults as examples: + +```env +VITE_API_URL=http://localhost:3000 +VITE_KEYCLOAK_URL=https://sso.greact.ru +VITE_KEYCLOAK_REALM=toir +VITE_KEYCLOAK_CLIENT_ID=toir-frontend +``` + diff --git a/auth/keycloak-architecture.md b/auth/keycloak-architecture.md new file mode 100644 index 0000000..b7480bb --- /dev/null +++ b/auth/keycloak-architecture.md @@ -0,0 +1,89 @@ +# Keycloak Architecture + +This repository generates a fullstack CRUD application with **default Keycloak authentication and authorization**. Authentication is not an optional add-on and must not be described as a manual post-generation task. + +--- + +# Default Auth Model + +The generated application must use this runtime topology: + +- **Frontend:** SPA served by Vite + React Admin +- **Backend:** NestJS API +- **Auth transport:** `Authorization: Bearer ` +- **Identity provider:** external Keycloak server + +The generated application must **not** use: + +- a BFF layer +- cookie/session authentication +- a custom in-app username/password login form + +The generated application must use **redirect-based Keycloak login** only. + +--- + +# Auth Is Part of Default Generation + +The generator must produce: + +- authenticated frontend bootstrap and request flow +- authenticated backend bootstrap and request guards +- auth-aware env examples +- auth-aware validation rules +- a root-level Keycloak realm import artifact that describes the Keycloak realm/client bootstrap + +Repository defaults may use names such as `toir`, `toir-frontend`, `toir-backend`, and `toir-realm.json`, but future generations must parameterize realm name, client IDs, production URLs, and artifact filename from the generated project or explicit auth configuration. + +The generated application must not require a separate prompt step such as "now add auth manually". + +--- + +# Public and Protected Routes + +The generated backend must keep: + +- `GET /health` — public + +All generated CRUD routes must be protected by default. + +Authorization must be role-based and must use **only** Keycloak realm roles from `realm_access.roles`. + +--- + +# Default Role Policy + +Apply these RBAC defaults to generated CRUD controllers: + +- `GET` list/detail: `viewer`, `editor`, `admin` +- `POST`, `PATCH`, `PUT`: `editor`, `admin` +- `DELETE`: `admin` + +The frontend may use roles for display and permission awareness, but the backend remains the enforcement point. + +--- + +# Token Contract + +Generated auth context must guarantee that access tokens used by the SPA and API reliably contain: + +- `sub` +- `aud` +- `realm_access.roles` + +The frontend client and backend audience/client must be documented explicitly. The generator must not rely on undocumented Keycloak defaults for these claims. + +--- + +# Runtime Boundaries + +The repository's local runtime topology remains unchanged: + +- `docker-compose.yml` provisions PostgreSQL only +- Keycloak remains external to this repo's Docker runtime + +The generator must therefore produce: + +- runtime documentation for importing/configuring the generated realm import artifact +- env contracts for frontend and backend +- validation rules that assume an external but reachable Keycloak server diff --git a/auth/keycloak-realm-template-rules.md b/auth/keycloak-realm-template-rules.md new file mode 100644 index 0000000..1acffb2 --- /dev/null +++ b/auth/keycloak-realm-template-rules.md @@ -0,0 +1,152 @@ +# Keycloak Realm Template Rules + +This document defines the required Keycloak realm/bootstrap artifact that the generator must produce. + +--- + +# Required Artifact + +The generator must create a root-level Keycloak import artifact: + +- project-specific realm import artifact +- repository default example filename: `toir-realm.json` + +This artifact is part of the normal generated result and must be documented in the runtime bootstrap flow. + +The generator must parameterize at minimum: + +- realm name +- frontend SPA client ID +- backend audience/resource client ID +- production frontend URLs +- realm-artifact filename + +Repository defaults may use `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `https://toir-frontend.greact.ru` as examples, but those names must not be treated as universal requirements for all future generated applications. + +--- + +# Strategy Choice + +This repository uses the **robust bootstrap strategy** by default. + +That means the generated realm/client setup must be: + +- self-contained +- reproducible after import +- explicit about audience delivery +- explicit about role delivery +- explicit about required claims + +The generator must **not** rely on built-in client scopes magically existing and attaching correctly after realm import. + +--- + +# Required Realm Structure + +The generated realm import artifact must define: + +- realm name derived from generated project auth config +- realm roles: + - `admin` + - `editor` + - `viewer` +- frontend SPA client ID derived from generated project auth config +- backend audience/resource client ID derived from generated project auth config +- dedicated audience client scope for audience delivery + - repository default example: `api-audience` + +The audience client scope must explicitly add the generated backend audience/client ID to SPA access tokens. + +--- + +# Required Frontend Client Rules + +The generated SPA client must be configured as a public SPA client with: + +- `publicClient: true` +- `standardFlowEnabled: true` +- `implicitFlowEnabled: false` +- `directAccessGrantsEnabled: false` +- `serviceAccountsEnabled: false` +- `pkce.code.challenge.method: S256` + +Default rule: + +- keep `fullScopeAllowed: true` + +Reason: + +- this repository uses realm-role RBAC +- earlier failures came from fragile or implicit scope behavior +- the robust default is explicit audience delivery plus explicit claim mappers, not a partially documented scoped-role setup + +If a future prompt chooses `fullScopeAllowed: false`, it must also fully document role scope mappings and validation rules. The generator must not switch to that strategy silently. + +--- + +# Required Claim Delivery + +The generated realm/client configuration must reliably produce access tokens containing: + +- `sub` +- `aud` +- `realm_access.roles` + +The generator must explicitly address claim delivery and must not leave it implicit. + +The generated realm import artifact must therefore define explicit protocol mappers for: + +- `sub` +- user identity claims needed by the frontend +- `realm_access.roles` + +The generator must not assume these claims will appear through undeclared built-in scopes. + +Post-generation validation must fail if the generated realm contract leaves `sub`, `aud`, or `realm_access.roles` implicit instead of explicitly delivered. + +--- + +# Required Role Delivery + +The generated realm/client configuration must explicitly deliver realm roles into the access token. + +Rules: + +1. Role delivery must be configured intentionally. +2. `realm_access.roles` must be present in the access token after import. +3. The generator must validate that realm role delivery is part of the generated realm artifact. + +--- + +# Redirect URIs and Web Origins + +The generated realm template guidance must document local and production frontend URLs derived from the generated project configuration. + +Repository default production examples may include: + +- `http://localhost:5173` +- `https://toir-frontend.greact.ru` + +The generated SPA client must include matching redirect URIs and web origins for the generated local and production frontend URLs. These values must be explicit in the generated realm artifact or in a generated artifact template with explicit placeholders and validation instructions. + +--- + +# Anti-Regression Rule + +The earlier broken realm-template behavior must not be reproduced. + +The generator context must explicitly prevent: + +- missing audience in SPA access tokens +- missing `sub` +- missing `realm_access.roles` +- realm imports that depend on undeclared built-in scopes being present +- ambiguous role-delivery behavior + +The generated realm/bootstrap guidance must be self-contained enough that another engineer can import the artifact and predict the access-token shape without relying on hidden Keycloak defaults. + +The generator must guarantee through the generated realm artifact plus post-generation validation that imported access tokens reliably contain: + +- `sub` +- `aud` +- `realm_access.roles` diff --git a/backend/architecture.md b/backend/architecture.md index 36b05a9..a8a7ec5 100644 --- a/backend/architecture.md +++ b/backend/architecture.md @@ -7,6 +7,7 @@ Backend stack: - NestJS - Prisma ORM - PostgreSQL +- jose The backend is generated from the DSL specification. @@ -32,6 +33,15 @@ src/ main.ts app.module.ts + auth/ + auth.module.ts + guards/ + decorators/ + interfaces/ + + config/ + env.validation.ts + modules/ {entity}/ @@ -105,6 +115,7 @@ Every generated backend must expose a **health endpoint** so that runtime and or ## Controller example ```typescript +@Public() @Controller("health") export class HealthController { @Get() @@ -118,6 +129,42 @@ Register the health controller in the root app module (or a dedicated health mod --- +# Authentication and Authorization + +The generated backend must include explicit auth infrastructure by default. + +Generate: + +- `AuthModule` +- JWT guard +- roles guard +- `@Public()` +- `@Roles()` +- typed authenticated principal +- typed env validation for auth/runtime variables + +Rules: + +1. `/health` must remain public. +2. Generated CRUD routes must be protected by default. +3. JWT verification must use issuer + audience + JWKS. +4. Authorization roles must be extracted only from `realm_access.roles`. +5. The generated backend must not use deprecated Keycloak-specific Node adapters. + +## CRUD RBAC defaults + +Apply these defaults to generated CRUD controllers: + +- `GET` -> `viewer`, `editor`, `admin` +- `POST` -> `editor`, `admin` +- `PATCH` -> `editor`, `admin` +- `PUT` -> `editor`, `admin` +- `DELETE` -> `admin` + +These defaults must be encoded in generated guards/decorators, not left as informal guidance. + +--- + # List Endpoint List endpoint must support pagination and filters via query parameters. @@ -247,8 +294,8 @@ See **Controller Rules** above for the rule that :pk must match the entity's pri # Environment and runtime -- **Environment variables:** Backend requires at least `DATABASE_URL`. See **backend/runtime-rules.md**. -- **.env:** Generated project must include a `.env` (and `.env.example`) with `DATABASE_URL` so the app starts without runtime errors. +- **Environment variables:** Backend requires runtime and auth variables. See **backend/runtime-rules.md**. +- **.env:** Generated project must include a `.env` (and `.env.example`) with database, auth, and CORS variables so the app starts without runtime errors. - **PrismaService:** Must follow **backend/prisma-service.md** (OnModuleInit, $connect; no beforeExit). - **Prisma client:** Add `"postinstall": "prisma generate"` (or equivalent) to package.json so the client is generated after install. - **Migrations:** Document or run `npx prisma migrate dev` after schema generation. See **backend/runtime-rules.md** and **generation/backend-generation.md**. diff --git a/backend/runtime-rules.md b/backend/runtime-rules.md index 57762a0..9f939b7 100644 --- a/backend/runtime-rules.md +++ b/backend/runtime-rules.md @@ -10,14 +10,24 @@ The backend **must** have a `.env` file at the project root (e.g. `server/.env` ## Required variables -| Variable | Required | Description | -| ------------ | -------- | --------------------------------------- | -| DATABASE_URL | Yes | PostgreSQL connection string for Prisma | +| Variable | Required | Description | +| -------------------- | -------- | ----------- | +| PORT | Yes | Backend listen port | +| DATABASE_URL | Yes | PostgreSQL connection string for Prisma | +| CORS_ALLOWED_ORIGINS | Yes | Comma-separated SPA origins allowed to call the API | +| KEYCLOAK_ISSUER_URL | Yes | Keycloak issuer URL used for JWT verification | +| KEYCLOAK_AUDIENCE | Yes | Backend audience/client expected in access tokens | +| KEYCLOAK_JWKS_URL | No | Optional explicit JWKS URL | ## Example ```env +PORT=3000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" +CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru" +KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir" +KEYCLOAK_AUDIENCE="toir-backend" +# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs" ``` ## Generation requirement @@ -25,7 +35,7 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" When generating the backend, **always** create: 1. **`.env.example`** — with placeholder DATABASE_URL and instructions. -2. **`.env`** — with the same placeholder so the app can start; user replaces with real values. +2. **`.env`** — with local development example values so the app can start. If the generator does not create `.env`, the first run will fail with: @@ -33,6 +43,34 @@ If the generator does not create `.env`, the first run will fail with: Environment variable not found: DATABASE_URL ``` +## Fail-fast requirement + +The generated backend must validate required runtime and auth variables at startup. + +Rules: + +1. Missing required auth variables must stop startup immediately. +2. The generated backend must not silently fall back to production auth settings in code. +3. Auth env validation must be implemented explicitly in generated config/bootstrap code. + +--- + +# Authentication Runtime Rules + +The generated backend must verify JWTs against Keycloak using: + +- issuer +- audience +- JWKS + +JWKS resolution priority must be exactly: + +1. explicit `KEYCLOAK_JWKS_URL` +2. OIDC discovery +3. `${issuer}/protocol/openid-connect/certs` + +The generated backend must extract authorization roles only from `realm_access.roles`. + --- # Prisma Client Generation @@ -85,10 +123,30 @@ Run after: --- +# CORS Rules + +The generated backend must support the SPA -> API bearer-token model explicitly. + +Rules: + +1. Use `CORS_ALLOWED_ORIGINS` as the allowed origin list. +2. Allow at minimum these example origins in `.env.example`: + - `http://localhost:5173` + - `https://toir-frontend.greact.ru` +3. Allow `Authorization` and JSON content headers. +4. Expose `Content-Range` because React Admin depends on it. +5. Do not enable credentials by default. + +--- + # Summary -| Requirement | Action | -| --------------- | ------------------------------------------------------------- | -| DATABASE_URL | Create `.env` and `.env.example` with DATABASE_URL | -| Prisma client | Run `npx prisma generate`; add `postinstall` script | -| Database schema | Document/run `npx prisma migrate dev` after schema generation | +| Requirement | Action | +| ----------------------- | ------ | +| Runtime env | Create `.env` and `.env.example` with DB, auth, and CORS variables | +| Fail-fast config | Validate required auth/runtime variables at startup | +| Prisma client | Run `npx prisma generate`; add `postinstall` script | +| Database schema | Document/run `npx prisma migrate dev` after schema generation | +| JWT verification | Use issuer + audience + JWKS with explicit resolution priority | +| Role extraction | Use only `realm_access.roles` | +| CORS | Support SPA -> API bearer flow and expose `Content-Range` | diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..d849548 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,5 @@ +VITE_API_URL=http://localhost:3000 +VITE_KEYCLOAK_URL=https://sso.greact.ru +VITE_KEYCLOAK_REALM=toir +VITE_KEYCLOAK_CLIENT_ID=toir-frontend + diff --git a/client/package-lock.json b/client/package-lock.json index 2aead9f..b045299 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^7.3.9", + "keycloak-js": "^26.2.3", "ra-data-simple-rest": "^5.14.4", "react": "^18.2.0", "react-admin": "^5.14.4", @@ -3572,6 +3573,15 @@ "jsonexport": "bin/jsonexport.js" } }, + "node_modules/keycloak-js": { + "version": "26.2.3", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.3.tgz", + "integrity": "sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/client/package.json b/client/package.json index 86bcda1..fb9fac3 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^7.3.9", + "keycloak-js": "^26.2.3", "ra-data-simple-rest": "^5.14.4", "react": "^18.2.0", "react-admin": "^5.14.4", diff --git a/client/src/App.tsx b/client/src/App.tsx index ad3a1ea..91a825f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { Admin, Resource } from 'react-admin'; import dataProvider from './dataProvider'; +import authProvider from './auth/authProvider'; import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList'; import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate'; @@ -17,7 +18,7 @@ import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit'; import { RepairOrderShow } from './resources/repair-order/RepairOrderShow'; const App = () => ( - + { + await initKeycloak(); + }, + + logout: async () => { + await logoutFromKeycloak(); + }, + + checkAuth: async () => { + await getValidAccessToken(); + }, + + checkError: async (error) => { + const status = error?.status; + + if (status === 401) { + await forceReauthentication(); + return Promise.reject(error); + } + + if (status === 403) { + return Promise.resolve(); + } + + return Promise.resolve(); + }, + + getIdentity: async () => getIdentity(), + + getPermissions: async () => getRealmRoles(), +}; + +export default authProvider; + diff --git a/client/src/auth/keycloak.ts b/client/src/auth/keycloak.ts new file mode 100644 index 0000000..2103915 --- /dev/null +++ b/client/src/auth/keycloak.ts @@ -0,0 +1,96 @@ +import Keycloak, { KeycloakTokenParsed } from 'keycloak-js'; +import { env } from '../config/env'; + +interface RealmAccessTokenParsed extends KeycloakTokenParsed { + realm_access?: { + roles: string[]; + }; +} + +const keycloak = new Keycloak({ + url: env.keycloakUrl, + realm: env.keycloakRealm, + clientId: env.keycloakClientId, +}); + +let keycloakInitPromise: Promise | null = null; +let refreshInFlight: Promise | null = null; + +export function getKeycloak() { + return keycloak; +} + +export async function initKeycloak() { + if (!keycloakInitPromise) { + keycloakInitPromise = keycloak + .init({ + onLoad: 'login-required', + pkceMethod: 'S256', + checkLoginIframe: false, + }) + .then((authenticated) => { + if (!authenticated) { + return keycloak.login({ redirectUri: window.location.href }); + } + }); + } + + await keycloakInitPromise; +} + +async function refreshAccessToken(minValiditySeconds = 30) { + if (!refreshInFlight) { + refreshInFlight = keycloak + .updateToken(minValiditySeconds) + .then(() => undefined) + .finally(() => { + refreshInFlight = null; + }); + } + + await refreshInFlight; +} + +export async function getValidAccessToken(minValiditySeconds = 30): Promise { + await initKeycloak(); + + if (!keycloak.authenticated) { + await keycloak.login({ redirectUri: window.location.href }); + throw new Error('User is not authenticated'); + } + + await refreshAccessToken(minValiditySeconds); + + if (!keycloak.token) { + throw new Error('Missing access token'); + } + + return keycloak.token; +} + +export async function forceReauthentication() { + keycloak.clearToken(); + await keycloak.login({ redirectUri: window.location.href }); +} + +export async function logoutFromKeycloak() { + await keycloak.logout({ redirectUri: window.location.origin }); +} + +export function getRealmRoles(): string[] { + const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined; + const roles = parsed?.realm_access?.roles; + return Array.isArray(roles) ? roles : []; +} + +export function getIdentity() { + const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined; + const id = parsed?.sub ?? 'unknown'; + const fullName = + parsed?.name ?? + parsed?.preferred_username ?? + parsed?.email ?? + 'Unknown User'; + + return { id, fullName }; +} diff --git a/client/src/config/env.ts b/client/src/config/env.ts new file mode 100644 index 0000000..818f12e --- /dev/null +++ b/client/src/config/env.ts @@ -0,0 +1,24 @@ +const REQUIRED_ENV_KEYS = [ + 'VITE_API_URL', + 'VITE_KEYCLOAK_URL', + 'VITE_KEYCLOAK_REALM', + 'VITE_KEYCLOAK_CLIENT_ID', +] as const; + +type RequiredEnvKey = (typeof REQUIRED_ENV_KEYS)[number]; + +function readRequiredEnv(key: RequiredEnvKey): string { + const value = import.meta.env[key]; + if (!value || !value.trim()) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +export const env = { + apiUrl: readRequiredEnv('VITE_API_URL'), + keycloakUrl: readRequiredEnv('VITE_KEYCLOAK_URL'), + keycloakRealm: readRequiredEnv('VITE_KEYCLOAK_REALM'), + keycloakClientId: readRequiredEnv('VITE_KEYCLOAK_CLIENT_ID'), +} as const; + diff --git a/client/src/dataProvider.ts b/client/src/dataProvider.ts index fd208f5..9a47d63 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -1,7 +1,19 @@ import { DataProvider, fetchUtils } from 'react-admin'; +import { getValidAccessToken } from './auth/keycloak'; +import { env } from './config/env'; -const apiUrl = 'http://localhost:3000'; -const httpClient = fetchUtils.fetchJson; +const apiUrl = env.apiUrl; + +const httpClient = async (url: string, options: fetchUtils.Options = {}) => { + const token = await getValidAccessToken(); + const headers = new Headers(options.headers ?? { Accept: 'application/json' }); + headers.set('Authorization', `Bearer ${token}`); + + return fetchUtils.fetchJson(url, { + ...options, + headers, + }); +}; const dataProvider: DataProvider = { getList: async (resource, params) => { diff --git a/client/src/main.tsx b/client/src/main.tsx index c018515..be3fe9e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,9 +1,26 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { initKeycloak } from './auth/keycloak'; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -); +const root = ReactDOM.createRoot(document.getElementById('root')!); + +async function bootstrap() { + await initKeycloak(); + + root.render( + + + , + ); +} + +bootstrap().catch((error) => { + console.error('Failed to initialize authentication', error); + + root.render( + +
Authentication initialization failed. Check your environment variables.
+
, + ); +}); diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 11f02fe..7de5aa4 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_KEYCLOAK_URL: string; + readonly VITE_KEYCLOAK_REALM: string; + readonly VITE_KEYCLOAK_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/architecture.md b/frontend/architecture.md index 42aefcb..7a43a47 100644 --- a/frontend/architecture.md +++ b/frontend/architecture.md @@ -7,11 +7,14 @@ Frontend stack: - Vite - React Admin - shadcn/ui +- Keycloak JS The frontend is generated from the DSL and API specification. Each entity becomes a React Admin resource. +The generated frontend must also include Keycloak authentication by default. + --- # Project Structure @@ -21,6 +24,17 @@ client/ App.tsx + main.tsx + + dataProvider.ts + + auth/ + keycloak.ts + authProvider.ts + + config/ + env.ts + resources/ {entity}/ @@ -35,6 +49,13 @@ client/ Each resource must be registered in App.tsx. +The generated `App.tsx` must register: + +- `dataProvider` +- `authProvider` + +The generated `Admin` root must enforce authenticated operation. The generated frontend must not operate anonymously once auth is enabled. + Example: ` in this shared seam. +3. Cover all React Admin operations, including references and bulk fetches. +4. Do not scatter auth headers across resource components. + +--- + +# Application Bootstrap + +The generated `main.tsx` must initialize Keycloak before rendering the SPA. + +Rules: + +1. Use redirect-based Keycloak login only. +2. Use Authorization Code + PKCE (`S256`). +3. Do not generate a custom in-app username/password login form. +4. Do not render the authenticated admin app before Keycloak initialization completes. + +--- + +# Config + +The generated frontend must include a dedicated config module in `src/config/`. + +Required env variables: + +- `VITE_API_URL` +- `VITE_KEYCLOAK_URL` +- `VITE_KEYCLOAK_REALM` +- `VITE_KEYCLOAK_CLIENT_ID` + +The generated frontend config must fail fast if required auth variables are missing. The generated frontend must not silently fall back to production auth settings in code. + --- # Foreign Keys @@ -120,4 +178,4 @@ React Admin resource name (used in `` and in `reference` Examples in App.tsx: - `` - `` -- `` \ No newline at end of file +- `` diff --git a/frontend/react-admin-rules.md b/frontend/react-admin-rules.md index 66ebee4..b67f185 100644 --- a/frontend/react-admin-rules.md +++ b/frontend/react-admin-rules.md @@ -4,6 +4,55 @@ Entity attributes determine UI fields. --- +# Authentication + +Generated React Admin applications in this repository must include an `authProvider`. + +Rules: + +1. `authProvider` is mandatory. +2. The generated app must use redirect-based Keycloak login only. +3. The generator must not create a custom in-app username/password form. +4. The generated app must initialize authentication before rendering the admin UI. + +--- + +# Shared Authenticated Request Layer + +The generated frontend must attach bearer tokens through the shared request seam in `client/src/dataProvider.ts`. + +Rules: + +1. All resource calls must use the same authenticated request layer. +2. Reference lookups must use the same authenticated request layer. +3. The generated frontend must not attach auth headers directly inside resource components. + +--- + +# Error Handling + +The generated `authProvider.checkError` must distinguish authentication failures from authorization failures: + +- `401` -> force logout / re-authentication +- `403` -> do not re-authenticate; surface access denied / permission error + +The generator must not treat `401` and `403` as the same outcome. + +--- + +# Token Handling + +The generated frontend must use Keycloak JS token handling with these rules: + +1. Use Authorization Code + PKCE (`S256`). +2. Refresh tokens before protected API calls when needed. +3. Token refresh must be concurrency-safe: + - one shared in-flight refresh operation + - no parallel refresh stampede +4. Do not store access tokens or refresh tokens in `localStorage` or `sessionStorage`. + +--- + # Type Mapping | DSL Type | React Admin Component | @@ -95,4 +144,4 @@ API response must include `id` so React Admin can identify the record: If the response only had `{ "code": "pump", "name": "Pump" }`, React Admin would not work correctly because it expects `id`. The backend or frontend adapter must therefore set `id: record.code` (or equivalent) when the primary key is not `id`. -This rule ensures compatibility with React Admin resource identity handling. \ No newline at end of file +This rule ensures compatibility with React Admin resource identity handling. diff --git a/general-prompt.md b/general-prompt.md index df1eaa5..2ad0bfe 100644 --- a/general-prompt.md +++ b/general-prompt.md @@ -14,6 +14,10 @@ Prisma documentation React Admin documentation +Vite documentation + +Keycloak documentation + Docker documentation The generated application must run without manual fixes. @@ -24,7 +28,7 @@ You must read the project documentation in the following strict order: domain/dsl-spec.md -examples/\*.dsl +examples/*.dsl backend/architecture.md @@ -44,6 +48,14 @@ frontend/architecture.md frontend/react-admin-rules.md +auth/keycloak-architecture.md + +auth/frontend-auth-rules.md + +auth/backend-auth-rules.md + +auth/keycloak-realm-template-rules.md + generation/scaffolding-rules.md generation/backend-generation.md @@ -58,7 +70,9 @@ Do not ignore any rules defined in these documents. GOAL -Generate a DSL-driven fullstack CRUD system. +Generate a DSL-driven fullstack CRUD system with default Keycloak authentication and authorization. + +Repository-specific defaults and examples may use names such as `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `*.greact.ru`, but the generator must parameterize realm name, client IDs, production URLs, and realm-artifact filename for other generated projects. Stack: @@ -72,6 +86,8 @@ Prisma ORM PostgreSQL +jose + Frontend React @@ -84,16 +100,21 @@ MUI shadcn/ui +Keycloak JS + PROJECT STRUCTURE Root docker-compose.yml +root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) server/ client/ Backend server/ src/ +auth/ +config/ modules/{entity}/ prisma/schema.prisma prisma/seed.ts @@ -101,9 +122,15 @@ prisma/seed.ts .env.example Frontend -client/src/resources/{entity}/ -client/src/App.tsx -client/src/dataProvider.ts +client/ +src/ +auth/ +config/ +resources/{entity}/ +App.tsx +main.tsx +dataProvider.ts +.env.example STEP 1 — Parse DSL @@ -134,6 +161,7 @@ Backend @prisma/client prisma @nestjs/config +jose Frontend @@ -142,6 +170,7 @@ ra-data-simple-rest @mui/material @emotion/react @emotion/styled +keycloak-js STEP 4 — Generate Prisma schema @@ -165,7 +194,7 @@ DTO mapping decimal → string date → ISO string -STEP 5 — Generate NestJS modules +STEP 5 — Generate NestJS CRUD modules Per entity generate: @@ -190,7 +219,31 @@ Examples /equipment-types/:code /repair-orders/:id -STEP 6 — Generate Service Layer +STEP 6 — Generate backend auth infrastructure + +Generate: + +AuthModule +JWT guard +roles guard +@Public() +@Roles() +typed authenticated principal +typed env validation + +Rules: + +- `/health` must remain public +- CRUD routes must be protected by default +- RBAC source must be `realm_access.roles` +- JWT verification must use issuer + audience + JWKS +- JWKS resolution priority must be: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. `${issuer}/protocol/openid-connect/certs` +- Do not use deprecated Keycloak-specific Node adapters + +STEP 7 — Generate Service Layer Service layer must follow backend/service-rules.md. @@ -229,7 +282,7 @@ data Example (PK = id) -const { id: \_pk, ...data } = dto +const { id: _pk, ...data } = dto return prisma.entity.update({ where: { id }, @@ -244,36 +297,58 @@ id primary key attribute readonly attributes -STEP 7 — Generate PrismaService +STEP 8 — Generate frontend auth integration -Requirements +Generate: -extends PrismaClient -implements OnModuleInit -await this.$connect() +client/src/config/env.ts +client/src/auth/keycloak.ts +client/src/auth/authProvider.ts -Do NOT use +Rules: -beforeExit +- Keycloak login must be redirect-based only +- Use Authorization Code + PKCE (`S256`) +- Initialize Keycloak before rendering the SPA +- Attach `Authorization: Bearer ` through the shared request seam in `client/src/dataProvider.ts` +- `401` must force re-authentication +- `403` must surface access denied without forcing re-authentication +- Token refresh must be concurrency-safe +- Do not store tokens in `localStorage` or `sessionStorage` +- Frontend auth config must fail fast if required auth vars are missing -STEP 8 — Generate runtime infrastructure +STEP 9 — Generate runtime infrastructure -Create +Create: server/.env server/.env.example +client/.env.example +root-level Keycloak realm import artifact (default example filename: `toir-realm.json`) -DATABASE_URL example +Backend env examples must include: -postgresql://postgres:postgres@localhost:5432/toir +PORT +DATABASE_URL +CORS_ALLOWED_ORIGINS +KEYCLOAK_ISSUER_URL +KEYCLOAK_AUDIENCE +KEYCLOAK_JWKS_URL (optional) -Add to package.json +Frontend env examples must include: + +VITE_API_URL +VITE_KEYCLOAK_URL +VITE_KEYCLOAK_REALM +VITE_KEYCLOAK_CLIENT_ID + +Add to package.json: postinstall: prisma generate -STEP 9 — Database runtime +STEP 10 — Database runtime -Generate root +Generate root: docker-compose.yml @@ -282,25 +357,25 @@ PostgreSQL container postgres:16 port 5432 -STEP 10 — Generate seed +STEP 11 — Generate seed -Create +Create: server/prisma/seed.ts -Seed minimal data for +Seed minimal data for: EquipmentType Equipment RepairOrder -Add to package.json +Add to package.json: prisma.seed -STEP 11 — Generate React Admin +STEP 12 — Generate React Admin resources -For each entity generate +For each entity generate: Field mapping @@ -310,7 +385,7 @@ date → DateInput enum → SelectInput FK → ReferenceInput -API responses MUST contain +API responses MUST contain: If PK ≠ id, map primary key to id. @@ -321,9 +396,9 @@ id: record.code, code: record.code } -STEP 12 — Validation +STEP 13 — Validation -Verify +Verify: docker-compose.yml exists database container starts @@ -332,26 +407,36 @@ prisma db seed works API responds /health React Admin receives id update services sanitize payload before Prisma +frontend auth files exist +backend auth files exist +auth env examples exist +public /health is preserved +unauthenticated protected route returns 401 +insufficient role returns 403 +generated realm import artifact is self-contained and guarantees `sub`, `aud`, and `realm_access.roles` OUTPUT -Provide +Provide: FULLSTACK GENERATION REPORT -Include +Include: 1 Parsed DSL 2 Prisma models 3 Backend modules 4 API endpoints 5 React Admin resources -6 Runtime configuration -7 Validation results +6 Authentication and authorization +7 Runtime configuration +8 Validation results RUN INSTRUCTIONS -The generated application must run successfully with +The generated application must run successfully with: + +Import the generated root-level Keycloak realm artifact (for example `toir-realm.json`) into the external Keycloak server docker compose up -d diff --git a/generation/backend-generation.md b/generation/backend-generation.md index a6b3bf6..6c69c79 100644 --- a/generation/backend-generation.md +++ b/generation/backend-generation.md @@ -1,12 +1,14 @@ # Backend Generation Process -Backend generation follows a pipeline aligned with runtime and validation docs: +Backend generation follows a pipeline aligned with runtime, auth, and validation docs: DSL ↓ CLI scaffolding ↓ -code generation +backend code generation +↓ +auth generation ↓ runtime infrastructure ↓ @@ -18,7 +20,18 @@ seed ↓ validation -Follow **backend/runtime-rules.md**, **backend/prisma-rules.md**, **backend/prisma-service.md**, **backend/database-runtime.md**, **backend/seed-rules.md**, and **backend/service-rules.md**. +Follow: + +- `backend/architecture.md` +- `backend/runtime-rules.md` +- `backend/prisma-rules.md` +- `backend/prisma-service.md` +- `backend/database-runtime.md` +- `backend/seed-rules.md` +- `backend/service-rules.md` +- `auth/keycloak-architecture.md` +- `auth/backend-auth-rules.md` +- `auth/keycloak-realm-template-rules.md` --- @@ -27,10 +40,12 @@ Follow **backend/runtime-rules.md**, **backend/prisma-rules.md**, **backend/pris Read DSL inputs and extract: - entities -- attributes (including primary key attribute name per entity) +- attributes, including the actual primary key attribute per entity - enums - foreign keys +The generator must treat auth as default runtime infrastructure, not as a DSL feature toggle. + --- # Step 2 — CLI scaffolding @@ -38,50 +53,118 @@ Read DSL inputs and extract: Use official CLIs before generating backend code: - NestJS project scaffold in `server/` (see `generation/scaffolding-rules.md`) -- Install backend dependencies (`@prisma/client`, `prisma`, `@nestjs/config`, and seed runner when needed) +- install backend dependencies: + - `@prisma/client` + - `prisma` + - `@nestjs/config` + - `jose` + - seed runner when needed by the chosen package tooling + +The generator must **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect`. --- -# Step 3 — Code generation +# Step 3 — Core backend code generation Generate backend source artifacts: -1. **Prisma schema** (`server/prisma/schema.prisma`) from domain DSL: +1. **Prisma schema** (`server/prisma/schema.prisma`) from the domain DSL: - attributes - primary keys - relations - enums 2. **NestJS modules** per entity: - module - - controller (path params use actual PK name: `:id`, `:code`, etc.) - - service (must sanitize update payload before Prisma — see **backend/service-rules.md**) + - controller + - service 3. **DTO files**: - `create-entity.dto.ts` - `update-entity.dto.ts` - `entity.response.dto.ts` (or equivalent) 4. **PrismaService**: - - `OnModuleInit` + `await this.$connect()` - - no `beforeExit` hook -5. **Service update methods**: Sanitize update payload before passing to Prisma (remove `id`, primary key, and readonly attributes from `data`). Do not pass the raw request body as `data` to `prisma.*.update()`. + - implement `OnModuleInit` + - call `await this.$connect()` in `onModuleInit()` + - do not use a `beforeExit` hook +5. **Service update methods**: + - sanitize update payload before Prisma + - remove `id`, the entity primary key, and readonly attributes from `data` + - do not pass the raw request body directly to `prisma.*.update()` Use mapping rules from `backend/prisma-rules.md`: + - DSL `decimal` -> DTO `string` - DSL `date` -> DTO `string` (ISO) --- -# Step 4 — Runtime infrastructure +# Step 4 — Backend auth generation -Generate runtime config files: +Generate auth infrastructure as part of the normal backend output. Auth must not be deferred to a manual post-step. + +Required generated auth artifacts: + +- `server/src/auth/auth.module.ts` +- JWT auth guard +- roles guard +- `@Public()` decorator +- `@Roles()` decorator +- typed authenticated principal interface +- typed auth-aware config validation in `server/src/config/` + +JWT verification rules: + +- verify JWTs with issuer, audience, and JWKS +- use a standards-based library such as `jose` +- resolve JWKS in this exact order: + 1. explicit `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. `${issuer}/protocol/openid-connect/certs` + +RBAC rules: + +- extract authorization roles only from `realm_access.roles` +- do not infer roles from other claims +- apply CRUD defaults by HTTP method: + - `GET` -> `viewer`, `editor`, `admin` + - `POST`, `PATCH`, `PUT` -> `editor`, `admin` + - `DELETE` -> `admin` +- mark `/health` as public with `@Public()` + +Controller generation rules: + +- generated CRUD controllers must receive auth decorators by default +- path params must still use the actual entity primary key name (`:id`, `:code`, etc.) +- public routes must be explicit rather than implicit + +--- + +# Step 5 — Runtime infrastructure + +Generate backend runtime config files: - `server/.env` - `server/.env.example` +- `server/src/config/*` typed validation helpers - `server/package.json` lifecycle: - `"postinstall": "prisma generate"` - `server/package.json` Prisma seed: - - `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` variant by project standard) + - `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` equivalent if that is the chosen project standard) -Commands that must be supported/documented: +The generated backend env contract must include: + +- `PORT` +- `DATABASE_URL` +- `CORS_ALLOWED_ORIGINS` +- `KEYCLOAK_ISSUER_URL` +- `KEYCLOAK_AUDIENCE` +- `KEYCLOAK_JWKS_URL` (optional) + +Fail-fast config rule: + +- backend startup must fail fast when required auth or database env vars are missing +- the generator must not silently fall back to production auth values in code + +Commands that must be supported and documented: - `npx prisma generate` - `npx prisma migrate dev` @@ -89,9 +172,9 @@ Commands that must be supported/documented: --- -# Step 5 — Database runtime +# Step 6 — Database runtime -Generator must create `docker-compose.yml` at the **project root** with PostgreSQL. +Create `docker-compose.yml` at the **project root** with PostgreSQL only. Minimum required compose characteristics: @@ -99,13 +182,15 @@ Minimum required compose characteristics: - `image: postgres:16` - `ports: ["5432:5432"]` -Credentials/database in compose must match `DATABASE_URL`. +Credentials and database name in compose must match `DATABASE_URL`. + +Keycloak must remain an external runtime dependency and must not be added to `docker-compose.yml` in this repository. --- -# Step 6 — Migration +# Step 7 — Migration -Apply schema to development database: +Apply schema to the development database: ```bash cd server @@ -114,7 +199,7 @@ npx prisma migrate dev --- -# Step 7 — Seed +# Step 8 — Seed Run development seed: @@ -127,12 +212,14 @@ Seed file location: `server/prisma/seed.ts`. --- -# Step 8 — Validation +# Step 9 — Validation -Run runtime and contract checks from `generation/post-generation-validation.md`, including: +Run runtime, auth, and contract checks from `generation/post-generation-validation.md`, including: - docker-compose exists and DB container starts - Prisma lifecycle commands succeed - seed runs -- `/health` responds -- React Admin receives `id` in every record \ No newline at end of file +- `/health` responds without auth +- protected routes reject unauthenticated requests with `401` +- authenticated users with insufficient role receive `403` +- React Admin-compatible API responses include `id` for every record diff --git a/generation/dev-workflow.md b/generation/dev-workflow.md index cd5559a..b553ba6 100644 --- a/generation/dev-workflow.md +++ b/generation/dev-workflow.md @@ -1,6 +1,6 @@ # Developer Workflow -This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation. +This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation, including authentication. --- @@ -9,12 +9,34 @@ This document describes the **developer workflow** for running a generated fulls - **Node.js** (LTS, e.g. 18+) - **npm** - **Docker** and **Docker Compose** (for the development database) +- **External Keycloak server** reachable by the generated frontend and backend + +The generated project must not require the developer to invent auth wiring manually after generation. --- # Workflow Steps -## 1. Start the database +## 1. Prepare Keycloak and env files + +From the project root, the generated project must include: + +- root-level generated Keycloak realm import artifact + - repository default example filename: `toir-realm.json` +- `server/.env.example` +- `client/.env.example` + +Required workflow: + +1. Copy the generated env examples to real env files as needed. +2. Fill all required backend and frontend auth variables. +3. Import or verify the generated Keycloak realm import artifact in the external Keycloak server before starting the app. + +The generator must document that auth config is fail-fast. Missing required auth env vars must stop startup instead of silently falling back to production values in code. + +--- + +## 2. Start the database From the **project root**: @@ -24,7 +46,7 @@ docker compose up -d This starts the PostgreSQL container defined in `docker-compose.yml`. Wait a few seconds for the database to accept connections. -Verify (optional): +Verify if needed: ```bash docker compose ps @@ -32,7 +54,7 @@ docker compose ps --- -## 2. Backend setup and start +## 3. Backend setup and start From the **server** directory: @@ -45,13 +67,15 @@ npx prisma db seed npm run start ``` -- `npm install` — installs dependencies and runs `postinstall` (for example `prisma generate`). -- `npx prisma generate` — explicitly generates Prisma client. -- `npx prisma migrate dev` — creates/applies migrations. -- `npx prisma db seed` — inserts minimal development data. -- `npm run start` — starts NestJS backend. +- `npm install` installs dependencies and runs `postinstall` when configured. +- `npx prisma generate` explicitly generates Prisma client. +- `npx prisma migrate dev` creates/applies migrations. +- `npx prisma db seed` inserts minimal development data. +- `npm run start` starts the NestJS backend. -The API should be available at the configured port (e.g. `http://localhost:3000`). Verify with: +The API should be available at the configured port (for example `http://localhost:3000`). + +Verify: ```bash curl http://localhost:3000/health @@ -59,9 +83,15 @@ curl http://localhost:3000/health Expected: `{ "status": "ok" }` (or equivalent). +Generated backend behavior must also ensure: + +- protected CRUD routes require authentication by default +- insufficient roles result in `403` +- `/health` remains public + --- -## 3. Frontend setup and start +## 4. Frontend setup and start In a **separate terminal**, from the **project root**: @@ -71,10 +101,15 @@ npm install npm run dev ``` -- `npm install` — installs frontend dependencies. -- `npm run dev` — starts the Vite dev server (e.g. `http://localhost:5173`). +- `npm install` installs frontend dependencies including `keycloak-js`. +- `npm run dev` starts the Vite dev server (for example `http://localhost:5173`). -Open the Vite URL in a browser; the React Admin app should load and use the backend API. +Open the frontend URL in a browser. The generated React Admin app must: + +- initialize Keycloak before render +- use redirect-based login only +- authenticate against the configured Keycloak realm/client +- call the backend with bearer tokens through the shared request seam --- @@ -82,8 +117,19 @@ Open the Vite URL in a browser; the React Admin app should load and use the back | Step | Command / location | |------|---------------------| +| Prepare Keycloak + env | Fill `server/.env` and `client/.env`; import or verify the generated realm import artifact | | Start database | From root: `docker compose up -d` | | Backend setup/start | `cd server && npm install && npx prisma generate && npx prisma migrate dev && npx prisma db seed && npm run start` | | Frontend setup/start | `cd client && npm install && npm run dev` | -The generator must produce all required artifacts (docker-compose, env, schema, migrations, seed, health endpoint) so that this workflow succeeds and the development environment is fully runnable. +The generator must produce all required artifacts so that this workflow succeeds: + +- docker-compose +- env examples +- schema +- migrations +- seed +- health endpoint +- frontend auth integration +- backend auth infrastructure +- root-level Keycloak realm import artifact diff --git a/generation/frontend-generation.md b/generation/frontend-generation.md index e141060..5b3641c 100644 --- a/generation/frontend-generation.md +++ b/generation/frontend-generation.md @@ -1,36 +1,138 @@ # Frontend Generation Process -Frontend generation uses the DSL and API specification. +Frontend generation must produce the React Admin CRUD application **and** the default Keycloak integration required by the runtime architecture. + +Follow: + +- `frontend/architecture.md` +- `frontend/react-admin-rules.md` +- `auth/keycloak-architecture.md` +- `auth/frontend-auth-rules.md` +- `auth/keycloak-realm-template-rules.md` --- # Step 1 — Parse DSL -Extract entities and attributes. +Extract: + +- entities +- attributes +- relation fields required for reference inputs and reference displays + +The generator must treat auth as default frontend infrastructure rather than as an optional feature. --- -# Step 2 — Generate React Admin Resources +# Step 2 — Generate frontend runtime structure + +Generate the base frontend structure required by the proven runtime: + +- `client/src/main.tsx` +- `client/src/App.tsx` +- `client/src/dataProvider.ts` +- `client/src/config/env.ts` +- `client/src/auth/keycloak.ts` +- `client/src/auth/authProvider.ts` +- `client/.env.example` + +The generated frontend must: + +- initialize Keycloak before rendering the SPA +- register React Admin with a mandatory `authProvider` +- enforce authenticated operation rather than anonymous operation +- use environment-driven runtime config and fail fast when required auth vars are missing + +--- + +# Step 3 — Generate React Admin resources For each entity create: -EntityList.tsx -EntityCreate.tsx -EntityEdit.tsx -EntityShow.tsx +- `EntityList.tsx` +- `EntityCreate.tsx` +- `EntityEdit.tsx` +- `EntityShow.tsx` + +Resource generation must remain compatible with the auth-aware shared request seam. --- -# Step 3 — Map Fields +# Step 4 — Map fields -Map DSL attributes to React Admin components. +Map DSL attributes to React Admin components according to existing field rules and relation semantics. + +Reference/resource lookups must continue to flow through the same shared authenticated request layer used by the main CRUD resources. --- -# Step 4 — Register Resources +# Step 5 — Generate auth-aware API layer -Register resources in App.tsx. +Generate a shared `dataProvider.ts` that: -Example: +- reads the API base URL from `VITE_API_URL` +- attaches `Authorization: Bearer ` to every backend request +- uses the same request seam for all React Admin operations, including: + - list + - get one + - get many + - get many reference + - create + - update + - delete +- handles token refresh before protected requests +- keeps token refresh concurrency-safe by sharing one in-flight refresh operation +- does not store access tokens or refresh tokens in `localStorage` or `sessionStorage` - \ No newline at end of file +The generator must not create multiple competing HTTP clients for authenticated and unauthenticated traffic. The shared request seam is the single bearer injection point. + +--- + +# Step 6 — Generate frontend auth flow + +Generate Keycloak frontend integration with these required rules: + +- use `keycloak-js` +- redirect-based login only +- no custom in-app username/password login form +- Authorization Code + PKCE with `S256` +- initialize Keycloak before React render +- provide a React Admin `authProvider` +- distinguish auth failures from authorization failures: + - `401` -> force logout / re-authentication + - `403` -> do not re-authenticate; surface access denied / permission error + +The generator must not silently fall back to production auth settings in code. Example values belong in `.env.example`, but runtime config must fail fast if required values are absent. + +--- + +# Step 7 — Register resources and auth + +Register resources in `App.tsx` and wire them into an authenticated `Admin` root. + +Generated `App.tsx` must: + +- register the shared `dataProvider` +- register the mandatory `authProvider` +- configure the app so it does not operate anonymously once auth is enabled + +Example shape: + +```tsx + + + +``` + +--- + +# Step 8 — Frontend runtime config + +Generate frontend env examples and config access for: + +- `VITE_API_URL` +- `VITE_KEYCLOAK_URL` +- `VITE_KEYCLOAK_REALM` +- `VITE_KEYCLOAK_CLIENT_ID` + +`client/.env.example` must use filled example values that match the documented Keycloak and local dev topology, while runtime code must fail fast if any required variable is missing. diff --git a/generation/post-generation-validation.md b/generation/post-generation-validation.md index 87cddd5..47f0b8d 100644 --- a/generation/post-generation-validation.md +++ b/generation/post-generation-validation.md @@ -1,160 +1,236 @@ # Post-Generation Validation -After generating the backend or fullstack application, run these checks to ensure the project will run without runtime errors. +After generating the backend or fullstack application, run these checks to ensure the project will start cleanly and that the default auth model has been generated correctly. --- # Validation Checklist -## 1. Environment file +## 1. Frontend and backend env files -- [ ] **`.env` exists** in the backend root (e.g. `server/.env`). -- [ ] **`.env.example` exists** with at least `DATABASE_URL` and a placeholder value. -- [ ] **`DATABASE_URL`** is present in `.env` (or documented in `.env.example` so the user can copy and set it). +- [ ] `server/.env.example` exists and documents: + - `PORT` + - `DATABASE_URL` + - `CORS_ALLOWED_ORIGINS` + - `KEYCLOAK_ISSUER_URL` + - `KEYCLOAK_AUDIENCE` + - optional `KEYCLOAK_JWKS_URL` +- [ ] `client/.env.example` exists and documents: + - `VITE_API_URL` + - `VITE_KEYCLOAK_URL` + - `VITE_KEYCLOAK_REALM` + - `VITE_KEYCLOAK_CLIENT_ID` +- [ ] Runtime code fails fast when required auth or database env vars are missing. +- [ ] Runtime code does not silently fall back to production auth settings. -**Failure symptom:** `Environment variable not found: DATABASE_URL` at startup. +**Failure symptoms:** startup succeeds with undefined auth config, or fails later with opaque auth/runtime errors. --- -## 2. PrismaService implementation +## 2. Keycloak realm artifact -- [ ] A **PrismaService** (or equivalent) class exists and extends `PrismaClient`. -- [ ] It implements **`OnModuleInit`** and calls **`await this.$connect()`** in `onModuleInit()`. -- [ ] It does **not** use **`this.$on('beforeExit', ...)`** or any `beforeExit` hook. +- [ ] A root-level generated Keycloak realm import artifact exists. +- [ ] If the repository default filename `toir-realm.json` is not used, the project-specific equivalent is documented consistently across bootstrap and workflow docs. +- [ ] The generated realm artifact is self-contained and reproducible. +- [ ] The generated realm artifact parameterizes realm name, frontend client ID, backend audience/client ID, production URLs, and artifact filename consistently with the generated project auth config. +- [ ] It defines realm roles: + - `admin` + - `editor` + - `viewer` +- [ ] It defines a frontend SPA client consistent with the generated frontend Keycloak client ID. +- [ ] It defines a backend audience/resource client consistent with the generated backend audience/client ID. +- [ ] It defines explicit audience delivery, such as an `api-audience` client scope. +- [ ] It does not rely on undeclared built-in client scopes being present after import. +- [ ] It explicitly addresses delivery of: + - `sub` + - `aud` + - `realm_access.roles` -**Failure symptom:** Deprecation/runtime errors in Prisma 5 when using `beforeExit`. +**Failure symptoms:** access tokens are missing required claims, or realm import succeeds but generated apps still cannot authenticate/authorize reliably. + +--- + +## 3. Frontend auth files and behavior + +- [ ] Generated frontend includes: + - `client/src/config/env.ts` + - `client/src/auth/keycloak.ts` + - `client/src/auth/authProvider.ts` + - `client/src/main.tsx` + - `client/src/App.tsx` + - `client/src/dataProvider.ts` +- [ ] `keycloak-js` is installed. +- [ ] Keycloak is initialized before the SPA renders. +- [ ] Login is redirect-based only. +- [ ] No custom in-app username/password login form is generated. +- [ ] `Authorization Code + PKCE (S256)` is encoded in the frontend auth flow. +- [ ] `client/src/dataProvider.ts` or the documented shared request seam injects `Authorization: Bearer ` into all API requests. +- [ ] Token refresh is concurrency-safe: + - one shared in-flight refresh operation + - no parallel refresh stampede +- [ ] Generated auth code does not persist access tokens or refresh tokens in `localStorage` or `sessionStorage`. +- [ ] React Admin auth semantics distinguish: + - `401` -> force logout / re-authentication + - `403` -> do not re-authenticate; surface access denied / permission error + +**Failure symptoms:** app renders before auth is ready, reference calls miss auth headers, refresh storms occur, or `403` incorrectly forces logout. + +--- + +## 4. Backend auth files and behavior + +- [ ] Generated backend includes: + - `server/src/auth/auth.module.ts` + - JWT guard + - roles guard + - `@Public()` decorator + - `@Roles()` decorator + - typed authenticated principal interface + - typed config validation in `server/src/config/` +- [ ] `jose` is installed. +- [ ] JWT verification uses issuer + audience + JWKS. +- [ ] JWKS resolution follows this exact priority: + 1. `KEYCLOAK_JWKS_URL` + 2. OIDC discovery + 3. `${issuer}/protocol/openid-connect/certs` +- [ ] Authorization roles are extracted only from `realm_access.roles`. +- [ ] Deprecated Keycloak-specific Node adapters are not used. + +**Failure symptoms:** invalid tokens are accepted, valid tokens are rejected due to bad JWKS resolution, or RBAC depends on unstable/non-standard claims. + +--- + +## 5. CRUD protection and RBAC defaults + +- [ ] `/health` is public. +- [ ] Each generated CRUD controller method other than explicit public routes is protected by the generated auth/RBAC infrastructure. +- [ ] CRUD RBAC defaults are present: + - `GET` -> `viewer | editor | admin` + - `POST`, `PATCH`, `PUT` -> `editor | admin` + - `DELETE` -> `admin` +- [ ] Unauthenticated request to a protected route returns `401`. +- [ ] Authenticated user with insufficient role receives `403`. + +**Failure symptoms:** anonymous CRUD access remains open, or insufficient-role users are not denied properly. + +--- + +## 6. PrismaService implementation + +- [ ] A `PrismaService` (or equivalent) class exists and extends `PrismaClient`. +- [ ] It implements `OnModuleInit` and calls `await this.$connect()` in `onModuleInit()`. +- [ ] It does not use `this.$on('beforeExit', ...)` or any `beforeExit` hook. + +**Failure symptom:** deprecation/runtime errors in Prisma 5 when using `beforeExit`. **Reference:** `backend/prisma-service.md` --- -## 3. Prisma client lifecycle +## 7. Prisma client lifecycle -- [ ] **`package.json`** includes a script that runs Prisma client generation: - - Either **`"postinstall": "prisma generate"`** (or `npx prisma generate`), - - Or clear documentation to run **`npx prisma generate`** after install. -- [ ] After schema generation or change, **`npx prisma generate`** has been run (or will run via postinstall). +- [ ] `package.json` includes a script that runs Prisma client generation: + - either `"postinstall": "prisma generate"` (or `npx prisma generate`) + - or clear documentation to run `npx prisma generate` after install +- [ ] After schema generation or change, `npx prisma generate` has been run or will run via `postinstall`. -**Failure symptom:** `Cannot find module '@prisma/client'` or missing types at build/run. +**Failure symptom:** `Cannot find module '@prisma/client'` or missing generated types. --- -## 4. Database migration +## 8. Database migration -- [ ] **Migration workflow** is documented (e.g. in README or generation docs). -- [ ] Instruction to run **`npx prisma migrate dev`** (or `prisma migrate deploy` for production) after first generation or schema change. -- [ ] Generation pipeline (see `generation/backend-generation.md`) includes or documents the migration step. +- [ ] Migration workflow is documented. +- [ ] Instruction to run `npx prisma migrate dev` exists after first generation or schema change. +- [ ] Generation pipeline includes or documents the migration step. -**Failure symptom:** Tables do not exist; Prisma errors on first query. +**Failure symptom:** tables do not exist; Prisma errors on first query. --- -## 5. REST route parameters +## 9. REST route parameters -- [ ] For each entity, path parameters use the **correct primary key name** from the DSL. -- [ ] **Entity with PK `id` (uuid):** routes use **`/:id`** (e.g. `GET /equipment/:id`, `PATCH /equipment/:id`). -- [ ] **Entity with non-`id` primary key (e.g. `code`):** routes use **`/:code`** (or the actual PK attribute name), e.g. `GET /equipment-types/:code`, **not** `GET /equipment-types/:id`. +- [ ] For each entity, path parameters use the correct primary key name from the DSL. +- [ ] Entity with PK `id` uses `/:id`. +- [ ] Entity with non-`id` primary key (for example `code`) uses the actual PK name such as `/:code`, not `/:id`. -**Example:** +**Failure symptom:** controller expects one param name while the route defines another, leading to broken CRUD behavior. -| Entity | Primary key | Correct path | Incorrect path | -| ------------- | ----------- | ---------------------- | -------------------- | -| Equipment | id | /equipment/:id | — | -| EquipmentType | code | /equipment-types/:code | /equipment-types/:id | -| RepairOrder | id | /repair-orders/:id | — | - -**Failure symptom:** Controller expects `params.id` but route is defined with `:code`; or vice versa; 404 or wrong resource updated. - -**Reference:** `backend/architecture.md` — API path rules for non-id primary keys. +**Reference:** `backend/architecture.md` --- -## 6. DTO type mapping (serialization) +## 10. DTO type mapping and React Admin ID compatibility -- [ ] **DSL `decimal`** → In DTO/API response, use **`string`** (or a type that serializes to string), not Prisma `Decimal`, to avoid JSON serialization issues. -- [ ] **DSL `date`** → In DTO/API response, use **`string`** (ISO 8601) or ensure DateTime is serialized to string, so React Admin and JSON consumers receive a string. +- [ ] DSL `decimal` maps to DTO/API `string`. +- [ ] DSL `date` maps to DTO/API `string` (ISO) or equivalent string serialization. +- [ ] Every API response object contains a field named `id`. +- [ ] If the entity primary key is not named `id`, the response maps the primary key to `id`. -**Reference:** `backend/prisma-rules.md` — DTO type mapping table. +**Failure symptoms:** serialization issues for decimals/dates, or React Admin cannot identify records. --- -## 7. React Admin ID field in API responses +## 11. Update payload sanitization -- [ ] **Every API response object** (list items and single-resource GET) contains a field named **`id`**. -- [ ] If the entity primary key is **not** named `id`, the response must **map** the primary key to `id`: e.g. `id: record.code` for EquipmentType, so the payload includes both `id` and `code` (or at least `id` with the PK value). -- [ ] The `id` field value must be the **primary key value** (string or uuid as appropriate). +- [ ] Update endpoints do not pass `id` or the primary key in Prisma `data`. +- [ ] Generated update methods remove `id`, the entity primary key, and readonly attributes before calling `prisma.*.update()`. -**Failure symptom:** React Admin fails to identify records, breaks cache/references, or throws when expecting `record.id`. - -**Reference:** `frontend/react-admin-rules.md` — React Admin ID Field Requirement. +**Failure symptom:** Prisma throws because immutable or invalid fields are passed in update `data`. --- -## 8. Update payload sanitization (service layer) +## 12. Database runtime -- [ ] **Update endpoint must not pass `id` (or primary key) in Prisma `data`.** The service must sanitize the incoming DTO before calling `prisma.*.update({ where, data })`: remove `id`, remove the entity primary key field (e.g. `code`), and remove any readonly attributes. Only updatable fields should be passed as `data`. -- [ ] Generated update methods follow the pattern from **backend/service-rules.md** (e.g. `const { id, code, ...data } = dto` then pass `data` to Prisma). +- [ ] `docker-compose.yml` exists at the project root. +- [ ] It defines a PostgreSQL service with image `postgres:16`, port `5432`, and credentials matching `DATABASE_URL`. +- [ ] `docker compose up -d` starts the database successfully. +- [ ] `docker-compose.yml` does not add Keycloak in this repository. -**Failure symptom:** Prisma throws when `data` contains `id` or another field that is not on the model or not writable (e.g. entity with PK `code` receives body with `id` from React Admin). - -**Reference:** `backend/service-rules.md` +**Failure symptom:** Prisma cannot reach the development database or repo topology drifts from the documented external-Keycloak model. --- -## 9. Database runtime (docker-compose) +## 13. Migrations, seed, and health endpoint -- [ ] **`docker-compose.yml` exists** at the project root (or documented location). -- [ ] It defines a **PostgreSQL** service with image (e.g. `postgres:16`), port `5432`, and credentials/DB name matching `DATABASE_URL` in `server/.env`. -- [ ] **Database container starts:** `docker compose up -d` runs without error and the container is reachable on the configured port. +- [ ] `npx prisma migrate dev` runs successfully from `server/`. +- [ ] Seed script exists at `server/prisma/seed.ts` (or equivalent). +- [ ] `npx prisma db seed` runs without error. +- [ ] Backend exposes `GET /health`. +- [ ] `GET /health` returns HTTP 200 with a status payload. -**Failure symptom:** `PrismaClientInitializationError P1001: Can't reach database server at localhost:5432`. - -**Reference:** `backend/database-runtime.md` - ---- - -## 10. Migrations and seed - -- [ ] **`npx prisma migrate dev`** runs successfully from `server/` when the database is up (schema is applied or created). -- [ ] **Seed script** exists at `server/prisma/seed.ts` (or equivalent) and creates minimal sample data (e.g. one EquipmentType, one Equipment, one RepairOrder). -- [ ] **`npx prisma db seed`** runs without error (package.json has `prisma.seed` configured and seed runner installed). - -**Reference:** `backend/seed-rules.md`, `generation/runtime-bootstrap.md` - ---- - -## 11. Health endpoint - -- [ ] Backend exposes **GET /health** (or equivalent health route). -- [ ] **API responds to /health:** With backend running, `GET http://localhost:/health` returns HTTP 200 and a body such as `{ "status": "ok" }`. - -**Reference:** `backend/architecture.md` — Health Endpoint +**Failure symptom:** development bootstrap cannot complete end-to-end. --- # Summary Table -| Check | Required artifact / rule | -| ------------------- | ---------------------------------------------- | -| .env | File exists with DATABASE_URL | -| DATABASE_URL | Present in .env or .env.example | -| PrismaService | OnModuleInit + $connect(); no beforeExit | -| prisma generate | postinstall script or documented step | -| Migration | Documented step: prisma migrate dev | -| REST path params | Use entity PK name (:id or :code, etc.) | -| Decimal/Date in DTO | Map to string for serialization | -| API response `id` | Every record has `id`; if PK ≠ id, map PK → id | -| Update payload | Service strips `id`, PK, readonly from data before Prisma | -| docker-compose.yml | Exists at project root; PostgreSQL service | -| Database container | Starts with `docker compose up -d` | -| prisma migrate dev | Runs successfully from server/ | -| Seed script | Exists; prisma db seed runs | -| GET /health | Backend responds with 200 and status payload | +| Check | Required artifact / rule | +| --- | --- | +| Frontend env | `client/.env.example` with required Vite auth vars | +| Backend env | `server/.env.example` with DB, CORS, and Keycloak vars | +| Fail-fast config | Startup fails when required auth env is missing | +| Realm artifact | Root generated realm import artifact with self-contained auth setup | +| Frontend auth | `keycloak.ts`, `authProvider.ts`, authenticated `dataProvider.ts` | +| Backend auth | `AuthModule`, guards, decorators, typed principal | +| JWKS strategy | explicit URL -> discovery -> certs fallback | +| Role source | `realm_access.roles` only | +| CRUD RBAC | GET viewer/editor/admin; write editor/admin; delete admin | +| `/health` | Public and returns 200 | +| Protected route unauthenticated | Returns `401` | +| Protected route insufficient role | Returns `403` | +| Token storage | No `localStorage` / `sessionStorage` persistence | +| Token refresh | Concurrency-safe single in-flight refresh | +| Prisma lifecycle | `OnModuleInit` + `$connect()`, no `beforeExit` | +| Update sanitization | Strip `id` / PK / readonly before Prisma update | +| React Admin `id` | Every record includes `id` | +| Database runtime | PostgreSQL compose exists and starts | --- # Integration with generation pipeline -1. **Backend generation** (see `generation/backend-generation.md`) should produce artifacts that satisfy the above by default. -2. After generation, run this checklist manually or via a script that parses generated code and config. -3. If any check fails, the AI context or generator should be updated so that future runs pass. +1. Backend and frontend generation must produce artifacts that satisfy the above by default. +2. Runtime bootstrap must include Keycloak realm import/verification before app startup. +3. After generation, run this checklist manually or via an automated script. +4. If any check fails, update the generator context so future runs pass without manual repair. diff --git a/generation/runtime-bootstrap.md b/generation/runtime-bootstrap.md index 34d208d..cf34c18 100644 --- a/generation/runtime-bootstrap.md +++ b/generation/runtime-bootstrap.md @@ -1,14 +1,42 @@ # Runtime Bootstrap -After project generation, the following commands must work in order so that the application runs without manual database provisioning or ad-hoc steps. +After project generation, the following commands and prerequisites must work in order so that the application runs without ad-hoc auth or database setup. -The generator must produce a **runnable development environment**: backend, frontend, and database must all be startable via a documented sequence. +The generator must produce a **runnable development environment** consisting of: + +- external Keycloak identity provider +- backend API +- frontend SPA +- PostgreSQL database + +The generator must also produce the runtime artifacts required to bootstrap auth from zero, including a root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but future generations must allow a project-specific equivalent. --- # Bootstrap Sequence -## 1. Start the database +## 1. Prepare Keycloak + +Before starting the application, ensure an external Keycloak server is reachable and import or verify the generated realm artifact: + +- root-level generated Keycloak realm import artifact +- repository default example filename: `toir-realm.json` + +The runtime instructions must require importing or verifying this realm before frontend/backend startup. + +Keycloak bootstrap expectations: + +- realm import is self-contained +- frontend client exists +- backend audience client exists +- realm roles exist +- issued access tokens reliably contain `sub`, `aud`, and `realm_access.roles` + +The generator must not rely on undeclared built-in client scopes magically existing after realm import. + +--- + +## 2. Start the database From the **project root**: @@ -16,11 +44,11 @@ From the **project root**: docker compose up -d ``` -This starts the PostgreSQL container. The backend will connect to it using `DATABASE_URL` from `server/.env`. +This starts the PostgreSQL container. The backend connects to it using `DATABASE_URL` from `server/.env`. --- -## 2. Backend setup and start +## 3. Backend setup and start ```bash cd server @@ -31,15 +59,22 @@ npx prisma db seed npm run start ``` -- `npm install` — installs dependencies and runs `postinstall` (e.g. `prisma generate`) if configured. -- `npx prisma generate` — ensures Prisma client is generated explicitly after install/schema changes. -- `npx prisma migrate dev` — creates/applies migrations and ensures the database schema exists. -- `npx prisma db seed` — populates minimal development data for immediate UI/API usage. -- `npm run start` — starts the NestJS server (default port e.g. 3000). +- `npm install` installs dependencies and runs `postinstall` if configured. +- `npx prisma generate` ensures Prisma client is generated explicitly after install or schema changes. +- `npx prisma migrate dev` creates/applies migrations. +- `npx prisma db seed` inserts minimal development data. +- `npm run start` starts the NestJS server. + +Backend startup requirements: + +- required database and auth env vars must be present +- startup must fail fast if required env vars are missing +- CORS must support the SPA -> API bearer-token model +- `/health` must remain public --- -## 3. Frontend setup and start +## 4. Frontend setup and start In a separate terminal, from the **project root**: @@ -49,16 +84,37 @@ npm install npm run dev ``` -- `npm run dev` — starts the Vite dev server (e.g. http://localhost:5173). +- `npm install` installs frontend dependencies including `keycloak-js`. +- `npm run dev` starts the Vite dev server. + +Frontend startup requirements: + +- required Vite auth vars must be present +- startup must fail fast if required auth vars are missing +- Keycloak must initialize before the SPA is rendered +- login must use redirect-based Keycloak authentication only --- # Success Criteria -After running the above: +After completing the bootstrap sequence: -- Database container is running; Prisma can connect. -- Backend responds (e.g. `GET /health` returns `{ "status": "ok" }`). -- Frontend loads and can call the backend API. +- Keycloak is reachable and the generated realm has been imported or verified. +- Database container is running and Prisma can connect. +- Backend responds on `/health` without auth. +- Protected backend routes require a valid bearer token. +- Frontend loads, redirects through Keycloak login when needed, and uses bearer auth for all API calls. -The generator is responsible for producing all artifacts (docker-compose, schema, migrations, seed, env, health endpoint) so that this sequence succeeds without additional manual setup. +The generator is responsible for producing all artifacts and instructions needed for this sequence: + +- `docker-compose.yml` +- schema +- migrations +- seed +- backend/frontend env examples +- health endpoint +- auth infrastructure +- root-level Keycloak realm import artifact + +Docker scope must remain limited to PostgreSQL. Keycloak remains an external dependency in this repository. diff --git a/generation/scaffolding-rules.md b/generation/scaffolding-rules.md index 5b6b9e1..fc27f90 100644 --- a/generation/scaffolding-rules.md +++ b/generation/scaffolding-rules.md @@ -2,7 +2,9 @@ The generator must use **official CLI tools** to create base project structures. -The AI must **not** manually generate the entire project skeleton (e.g. by writing all config files and folder structure by hand). Using the CLI reduces errors and ensures compatibility with current tool versions. +The AI must **not** manually generate the entire project skeleton by hand. CLI scaffolding reduces drift and keeps the generated project aligned with current NestJS and Vite conventions. + +Auth is part of the default generated runtime. Scaffolding must therefore install the required frontend and backend auth dependencies during the normal project bootstrap path. --- @@ -19,9 +21,9 @@ npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git ## Rules - **Project directory** must be `server`. -- **TypeScript** must be used (default for Nest CLI). -- **npm** must be the package manager (`--package-manager npm`). -- **Git** initialization must be skipped (`--skip-git`). +- **TypeScript** must be used. +- **npm** must be the package manager. +- **Git** initialization must be skipped. ## After scaffolding — install required dependencies @@ -29,10 +31,16 @@ Run from the `server` directory: ```bash npm install @prisma/client -npm install prisma --save-dev npm install @nestjs/config +npm install jose +npm install prisma --save-dev ``` +## Backend auth dependency rules + +- `jose` must be installed by default because JWT verification is part of the default generated backend. +- The generator must **not** install deprecated Keycloak-specific Node adapters such as `keycloak-connect`. + --- # Frontend Scaffolding @@ -48,7 +56,7 @@ npm create vite@5.2.0 client -- --template react-ts ## Rules - **Project directory** must be `client`. -- **React + TypeScript** template must be used (`--template react-ts`). +- **React + TypeScript** template must be used. ## After scaffolding — install required dependencies @@ -58,8 +66,14 @@ Run from the `client` directory: npm install react-admin npm install ra-data-simple-rest npm install @mui/material @emotion/react @emotion/styled +npm install keycloak-js ``` +## Frontend auth dependency rules + +- `keycloak-js` must be installed by default because redirect-based Keycloak login is part of the default generated frontend. +- The generated frontend must use `keycloak-js` for Authorization Code + PKCE and must not generate a custom in-app username/password login form. + --- # Scaffolding Strategy @@ -67,12 +81,12 @@ npm install @mui/material @emotion/react @emotion/styled Generation pipeline order: 1. **Parse DSL** — Read domain, DTO, API, and UI DSL files. -2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install dependencies as above. -3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService, and React Admin resources. -4. **Runtime infrastructure** — Generate `.env`, `.env.example`, package lifecycle scripts, and runtime config files. +2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install runtime and auth dependencies listed above. +3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService/auth infrastructure, and React Admin resources/auth integration. +4. **Runtime infrastructure** — Generate backend/frontend `.env.example`, runtime config files, lifecycle scripts, and a root-level Keycloak realm import artifact (repository default example filename: `toir-realm.json`). 5. **Database runtime** — Generate `docker-compose.yml` in project root with PostgreSQL service (`postgres`, image `postgres:16`, port `5432:5432`). 6. **Migration** — Apply schema with `npx prisma migrate dev`. 7. **Seed** — Populate minimal development data with `npx prisma db seed`. -8. **Validation** — Run checks from `generation/post-generation-validation.md`. +8. **Validation** — Run checks from `generation/post-generation-validation.md`, including auth validation and realm-template validation. -Scaffolding (steps 1–2) must be done with the CLI; steps 3–8 are generated from the DSL and project docs. +Scaffolding (steps 1–2) must be done with the CLI. Steps 3–8 must be generated from the DSL and the project context documents, including the auth-specific context in `auth/*.md`. diff --git a/generation/update-strategy.md b/generation/update-strategy.md index 6e15242..cf5cc10 100644 --- a/generation/update-strategy.md +++ b/generation/update-strategy.md @@ -1,8 +1,40 @@ # Update Strategy -When DSL changes: +When the DSL changes, regeneration must preserve the default auth-enabled runtime rather than falling back to CRUD-only output. -1. Regenerate prisma.schema -2. Run prisma migrate dev -3. Regenerate Nest modules -4. Regenerate React Admin resources \ No newline at end of file +## Required regeneration sequence + +1. Regenerate `prisma/schema.prisma`. +2. Run `npx prisma migrate dev`. +3. Regenerate NestJS entity modules, DTOs, controllers, and services. +4. Regenerate backend auth infrastructure: + - `AuthModule` + - guards + - decorators + - typed authenticated principal + - typed config validation + - CRUD RBAC decorations +5. Regenerate React Admin resources. +6. Regenerate frontend auth infrastructure: + - `src/config/env.ts` + - `src/auth/keycloak.ts` + - `src/auth/authProvider.ts` + - authenticated `dataProvider.ts` + - `App.tsx` auth wiring + - `main.tsx` init-before-render flow +7. Regenerate backend and frontend `.env.example` files so the auth env contract stays in sync. +8. Regenerate the root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but the generator must allow a project-specific equivalent. +9. Re-run post-generation validation, including: + - auth dependency checks + - fail-fast env checks + - `/health` public check + - unauthenticated protected route -> `401` + - insufficient role -> `403` + - realm-template validation + +## Guardrails + +- Regeneration must not strip auth back out of the project. +- Auth remains outside the DSL grammar, but it is part of the default generated runtime. +- If a DSL change affects entities or routes, the generator must re-apply the default CRUD RBAC rules automatically. +- If a DSL change affects runtime topology or naming, the generator must keep backend/frontend env examples, CORS rules, and the generated realm import artifact aligned with the generated app. diff --git a/server/.env.example b/server/.env.example index 43743fe..cd9c83f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1 +1,7 @@ +PORT=3000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir" +CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru" +KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir" +KEYCLOAK_AUDIENCE="toir-backend" +# Optional: if omitted, backend uses OIDC discovery and then falls back to issuer + /protocol/openid-connect/certs +# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs" diff --git a/server/package-lock.json b/server/package-lock.json index acaa367..16c0ade 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^5.22.0", + "jose": "^6.2.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -6675,6 +6676,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index b799dbd..6b99780 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^5.22.0", + "jose": "^6.2.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 3461820..daad56b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { AuthModule } from './auth/auth.module'; +import { validateEnvironment } from './config/env.validation'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module'; @@ -8,7 +10,11 @@ import { RepairOrderModule } from './modules/repair-order/repair-order.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ + isGlobal: true, + validate: validateEnvironment, + }), + AuthModule, PrismaModule, HealthModule, EquipmentTypeModule, diff --git a/server/src/auth/auth.constants.ts b/server/src/auth/auth.constants.ts new file mode 100644 index 0000000..003c912 --- /dev/null +++ b/server/src/auth/auth.constants.ts @@ -0,0 +1,3 @@ +export const IS_PUBLIC_KEY = 'isPublic'; +export const ROLES_KEY = 'roles'; + diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts new file mode 100644 index 0000000..9611c7a --- /dev/null +++ b/server/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; + +@Module({ + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + ], + exports: [AuthService], +}) +export class AuthModule {} + diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts new file mode 100644 index 0000000..b2d9b36 --- /dev/null +++ b/server/src/auth/auth.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { RuntimeEnvironment } from '../config/env.validation'; +import { + AuthenticatedUser, + KeycloakJwtPayload, +} from './interfaces/authenticated-user.interface'; + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly issuerUrl: string; + private readonly audience: string; + private readonly explicitJwksUrl?: string; + + private jwksResolverPromise: Promise> | null = + null; + private jwksResolver: ReturnType | null = null; + + constructor( + private readonly configService: ConfigService, + ) { + this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL'); + this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE'); + this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL'); + } + + async verifyAccessToken(token: string): Promise { + try { + const jwksResolver = await this.getJwksResolver(); + + const { payload } = await jwtVerify(token, jwksResolver, { + issuer: this.issuerUrl, + audience: this.audience, + }); + + return this.mapPayloadToUser(payload as KeycloakJwtPayload); + } catch (error) { + this.logger.warn( + `JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + throw new UnauthorizedException('Invalid or expired access token'); + } + } + + private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser { + if (!payload.sub) { + throw new UnauthorizedException('Token subject is missing'); + } + + const roles = Array.isArray(payload.realm_access?.roles) + ? payload.realm_access.roles.filter( + (role): role is string => typeof role === 'string', + ) + : []; + + return { + sub: payload.sub, + username: payload.preferred_username, + name: payload.name, + email: payload.email, + roles, + claims: payload, + }; + } + + private async getJwksResolver() { + if (this.jwksResolver) { + return this.jwksResolver; + } + + if (!this.jwksResolverPromise) { + this.jwksResolverPromise = this.createJwksResolver() + .then((resolver) => { + this.jwksResolver = resolver; + return resolver; + }) + .finally(() => { + this.jwksResolverPromise = null; + }); + } + + return this.jwksResolverPromise; + } + + private async createJwksResolver() { + const jwksUrl = await this.resolveJwksUrl(); + this.logger.log(`Using JWKS URL: ${jwksUrl}`); + return createRemoteJWKSet(new URL(jwksUrl)); + } + + private async resolveJwksUrl(): Promise { + if (this.explicitJwksUrl) { + return this.explicitJwksUrl; + } + + const issuer = this.issuerUrl.replace(/\/+$/, ''); + const discoveryUrl = `${issuer}/.well-known/openid-configuration`; + + try { + const discoveryResponse = await fetch(discoveryUrl, { + headers: { + Accept: 'application/json', + }, + }); + + if (discoveryResponse.ok) { + const discoveryDocument = (await discoveryResponse.json()) as { + jwks_uri?: string; + }; + + if ( + typeof discoveryDocument.jwks_uri === 'string' && + discoveryDocument.jwks_uri.trim().length > 0 + ) { + return discoveryDocument.jwks_uri; + } + } + } catch (error) { + this.logger.warn( + `OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + + return `${issuer}/protocol/openid-connect/certs`; + } +} + diff --git a/server/src/auth/decorators/public.decorator.ts b/server/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..41d2571 --- /dev/null +++ b/server/src/auth/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { IS_PUBLIC_KEY } from '../auth.constants'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + diff --git a/server/src/auth/decorators/roles.decorator.ts b/server/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..0101ed5 --- /dev/null +++ b/server/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { ROLES_KEY } from '../auth.constants'; +import { RealmRole } from '../roles/realm-role.enum'; + +export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles); + diff --git a/server/src/auth/guards/jwt-auth.guard.ts b/server/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..0e6a938 --- /dev/null +++ b/server/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { IS_PUBLIC_KEY } from '../auth.constants'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly authService: AuthService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + + if (!token) { + throw new UnauthorizedException('Missing bearer token'); + } + + request.user = await this.authService.verifyAccessToken(token); + return true; + } + + private extractBearerToken(request: Request): string | null { + const authorization = request.headers.authorization; + if (!authorization) { + return null; + } + + const [scheme, token] = authorization.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return null; + } + + return token; + } +} + diff --git a/server/src/auth/guards/roles.guard.ts b/server/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..94f6393 --- /dev/null +++ b/server/src/auth/guards/roles.guard.ts @@ -0,0 +1,49 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants'; +import { RealmRole } from '../roles/realm-role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const requiredRoles = + this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? []; + + if (requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRoles = request.user?.roles ?? []; + const hasRequiredRole = requiredRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRequiredRole) { + throw new ForbiddenException('Access denied: insufficient role'); + } + + return true; + } +} + diff --git a/server/src/auth/interfaces/authenticated-user.interface.ts b/server/src/auth/interfaces/authenticated-user.interface.ts new file mode 100644 index 0000000..30c4599 --- /dev/null +++ b/server/src/auth/interfaces/authenticated-user.interface.ts @@ -0,0 +1,20 @@ +import { JWTPayload } from 'jose'; + +export interface KeycloakJwtPayload extends JWTPayload { + preferred_username?: string; + name?: string; + email?: string; + realm_access?: { + roles?: string[]; + }; +} + +export interface AuthenticatedUser { + sub: string; + username?: string; + name?: string; + email?: string; + roles: string[]; + claims: KeycloakJwtPayload; +} + diff --git a/server/src/auth/interfaces/express-request.interface.ts b/server/src/auth/interfaces/express-request.interface.ts new file mode 100644 index 0000000..696ab5a --- /dev/null +++ b/server/src/auth/interfaces/express-request.interface.ts @@ -0,0 +1,12 @@ +import { AuthenticatedUser } from './authenticated-user.interface'; + +declare global { + namespace Express { + interface Request { + user?: AuthenticatedUser; + } + } +} + +export {}; + diff --git a/server/src/auth/roles/realm-role.enum.ts b/server/src/auth/roles/realm-role.enum.ts new file mode 100644 index 0000000..e6856b9 --- /dev/null +++ b/server/src/auth/roles/realm-role.enum.ts @@ -0,0 +1,6 @@ +export enum RealmRole { + Admin = 'admin', + Editor = 'editor', + Viewer = 'viewer', +} + diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts new file mode 100644 index 0000000..4fed5dc --- /dev/null +++ b/server/src/config/env.validation.ts @@ -0,0 +1,58 @@ +export interface RuntimeEnvironment { + PORT: number; + DATABASE_URL: string; + CORS_ALLOWED_ORIGINS: string; + KEYCLOAK_ISSUER_URL: string; + KEYCLOAK_AUDIENCE: string; + KEYCLOAK_JWKS_URL?: string; +} + +function getRequiredString( + config: Record, + key: keyof RuntimeEnvironment, +): string { + const value = config[key]; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value.trim(); +} + +function getOptionalString( + config: Record, + key: keyof RuntimeEnvironment, +): string | undefined { + const value = config[key]; + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function parsePort(value: unknown): number { + if (value === undefined || value === null || value === '') { + return 3000; + } + + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('Environment variable PORT must be an integer between 1 and 65535'); + } + + return port; +} + +export function validateEnvironment( + config: Record, +): RuntimeEnvironment { + return { + PORT: parsePort(config.PORT), + DATABASE_URL: getRequiredString(config, 'DATABASE_URL'), + CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'), + KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'), + KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'), + KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'), + }; +} + diff --git a/server/src/health/health.controller.ts b/server/src/health/health.controller.ts index 4b0e136..24ddbb2 100644 --- a/server/src/health/health.controller.ts +++ b/server/src/health/health.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +@Public() @Controller('health') export class HealthController { @Get() diff --git a/server/src/main.ts b/server/src/main.ts index bfb39ea..2f858bb 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,12 +1,35 @@ import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; import { AppModule } from './app.module'; +import { RuntimeEnvironment } from './config/env.validation'; async function bootstrap() { const app = await NestFactory.create(AppModule); + const configService = app.get>( + ConfigService, + ); + + const allowedOrigins = configService + .getOrThrow('CORS_ALLOWED_ORIGINS') + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + app.enableCors({ - origin: true, + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + callback(new Error(`Origin ${origin} is not allowed by CORS`), false); + }, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Authorization', 'Content-Type'], exposedHeaders: ['Content-Range'], + credentials: false, }); - await app.listen(process.env.PORT ?? 3000); + + const port = configService.get('PORT', 3000); + await app.listen(port); } bootstrap(); diff --git a/server/src/modules/equipment-type/equipment-type.controller.ts b/server/src/modules/equipment-type/equipment-type.controller.ts index 14d21e5..a5c9984 100644 --- a/server/src/modules/equipment-type/equipment-type.controller.ts +++ b/server/src/modules/equipment-type/equipment-type.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common'; import { Response } from 'express'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { RealmRole } from '../../auth/roles/realm-role.enum'; import { EquipmentTypeService } from './equipment-type.service'; import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto'; import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; @@ -8,6 +10,7 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; export class EquipmentTypeController { constructor(private readonly equipmentTypeService: EquipmentTypeService) {} + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get() async findAll(@Query() query: any, @Res() res: Response) { const result = await this.equipmentTypeService.findAll(query); @@ -16,21 +19,25 @@ export class EquipmentTypeController { return res.json(result.data); } + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get(':code') findOne(@Param('code') code: string) { return this.equipmentTypeService.findOne(code); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Post() create(@Body() dto: CreateEquipmentTypeDto) { return this.equipmentTypeService.create(dto); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Patch(':code') update(@Param('code') code: string, @Body() dto: UpdateEquipmentTypeDto) { return this.equipmentTypeService.update(code, dto); } + @Roles(RealmRole.Admin) @Delete(':code') remove(@Param('code') code: string) { return this.equipmentTypeService.remove(code); diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts index 3ab81e3..fad2ff4 100644 --- a/server/src/modules/equipment/equipment.controller.ts +++ b/server/src/modules/equipment/equipment.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common'; import { Response } from 'express'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { RealmRole } from '../../auth/roles/realm-role.enum'; import { EquipmentService } from './equipment.service'; import { CreateEquipmentDto } from './dto/create-equipment.dto'; import { UpdateEquipmentDto } from './dto/update-equipment.dto'; @@ -8,6 +10,7 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto'; export class EquipmentController { constructor(private readonly equipmentService: EquipmentService) {} + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get() async findAll(@Query() query: any, @Res() res: Response) { const result = await this.equipmentService.findAll(query); @@ -16,21 +19,25 @@ export class EquipmentController { return res.json(result.data); } + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get(':id') findOne(@Param('id') id: string) { return this.equipmentService.findOne(id); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Post() create(@Body() dto: CreateEquipmentDto) { return this.equipmentService.create(dto); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) { return this.equipmentService.update(id, dto); } + @Roles(RealmRole.Admin) @Delete(':id') remove(@Param('id') id: string) { return this.equipmentService.remove(id); diff --git a/server/src/modules/repair-order/repair-order.controller.ts b/server/src/modules/repair-order/repair-order.controller.ts index 8787894..af4ee24 100644 --- a/server/src/modules/repair-order/repair-order.controller.ts +++ b/server/src/modules/repair-order/repair-order.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common'; import { Response } from 'express'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { RealmRole } from '../../auth/roles/realm-role.enum'; import { RepairOrderService } from './repair-order.service'; import { CreateRepairOrderDto } from './dto/create-repair-order.dto'; import { UpdateRepairOrderDto } from './dto/update-repair-order.dto'; @@ -8,6 +10,7 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto'; export class RepairOrderController { constructor(private readonly repairOrderService: RepairOrderService) {} + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get() async findAll(@Query() query: any, @Res() res: Response) { const result = await this.repairOrderService.findAll(query); @@ -16,21 +19,25 @@ export class RepairOrderController { return res.json(result.data); } + @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Get(':id') findOne(@Param('id') id: string) { return this.repairOrderService.findOne(id); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Post() create(@Body() dto: CreateRepairOrderDto) { return this.repairOrderService.create(dto); } + @Roles(RealmRole.Editor, RealmRole.Admin) @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) { return this.repairOrderService.update(id, dto); } + @Roles(RealmRole.Admin) @Delete(':id') remove(@Param('id') id: string) { return this.repairOrderService.remove(id); diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts index 50cda62..ce1c8f7 100644 --- a/server/test/app.e2e-spec.ts +++ b/server/test/app.e2e-spec.ts @@ -1,24 +1,83 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; +import { AuthService } from '../src/auth/auth.service'; +import { AuthenticatedUser } from '../src/auth/interfaces/authenticated-user.interface'; +import { PrismaService } from '../src/prisma/prisma.service'; import { AppModule } from './../src/app.module'; -describe('AppController (e2e)', () => { +describe('Auth and Health (e2e)', () => { let app: INestApplication; + let authServiceMock: { + verifyAccessToken: jest.Mock, [string]>; + }; + + beforeAll(async () => { + process.env.PORT = '3000'; + process.env.DATABASE_URL = + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/toir'; + process.env.CORS_ALLOWED_ORIGINS = + process.env.CORS_ALLOWED_ORIGINS ?? + 'http://localhost:5173,https://toir-frontend.greact.ru'; + process.env.KEYCLOAK_ISSUER_URL = + process.env.KEYCLOAK_ISSUER_URL ?? 'https://sso.greact.ru/realms/toir'; + process.env.KEYCLOAK_AUDIENCE = + process.env.KEYCLOAK_AUDIENCE ?? 'toir-backend'; + + authServiceMock = { + verifyAccessToken: jest.fn, [string]>(), + }; - beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(AuthService) + .useValue(authServiceMock) + .overrideProvider(PrismaService) + .useValue({}) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); }); - it('/ (GET)', () => { + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + authServiceMock.verifyAccessToken.mockReset(); + }); + + it('/health (GET) is public', () => { return request(app.getHttpServer()) - .get('/') + .get('/health') .expect(200) - .expect('Hello World!'); + .expect({ status: 'ok' }); + }); + + it('/equipment (GET) requires authentication', () => { + return request(app.getHttpServer()).get('/equipment').expect(401); + }); + + it('/equipment (POST) returns 403 for authenticated viewer role', async () => { + authServiceMock.verifyAccessToken.mockResolvedValue({ + sub: 'viewer-user', + username: 'viewer-user', + roles: ['viewer'], + claims: { + sub: 'viewer-user', + realm_access: { + roles: ['viewer'], + }, + }, + }); + + await request(app.getHttpServer()) + .post('/equipment') + .set('Authorization', 'Bearer viewer-token') + .send({}) + .expect(403); }); }); diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json index e9d912f..e9166a0 100644 --- a/server/test/jest-e2e.json +++ b/server/test/jest-e2e.json @@ -5,5 +5,8 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^jose$": "/mocks/jose.ts" } } diff --git a/server/test/mocks/jose.ts b/server/test/mocks/jose.ts new file mode 100644 index 0000000..97a9d97 --- /dev/null +++ b/server/test/mocks/jose.ts @@ -0,0 +1,8 @@ +export const createRemoteJWKSet = () => { + return async () => ({}) as never; +}; + +export const jwtVerify = async () => { + return { payload: {} }; +}; +