From 6fc4a2b67ad8a90ca31723520773f1ea735d0d39 Mon Sep 17 00:00:00 2001 From: Rafael Gomides Date: Wed, 25 Mar 2026 23:27:19 -0300 Subject: [PATCH] Implement core identity with accounts, sessions, and auth flows --- .env.example | 2 + CHANGELOG.md | 6 + README.md | 90 +++++-- docs/architecture.md | 44 ++-- docs/commands.md | 17 +- docs/handoff.md | 50 ++-- docs/phase-status.md | 26 +- migrations/0001_core_identity.sql | 40 +++ package-lock.json | 238 +++++++++++++++++- package.json | 6 + src/bootstrap/application.factory.ts | 12 +- src/bootstrap/config/app-config.ts | 20 ++ .../config/application-config.service.ts | 4 + src/bootstrap/errors/http-exception.filter.ts | 186 ++++++++++++++ .../persistence/database.executor.ts | 48 ++++ src/bootstrap/persistence/database.module.ts | 6 +- src/bootstrap/persistence/database.service.ts | 3 + .../persistence/migration.service.ts | 96 +++++++ .../persistence/migrations/run-migrations.ts | 31 +++ .../dto/authenticated-principal.dto.ts | 11 + .../application/ports/access-token.service.ts | 13 + .../application/ports/password-hasher.port.ts | 6 + .../use-cases/create-user-account.use-case.ts | 106 ++++++++ .../use-cases/invalidate-session.use-case.ts | 45 ++++ .../use-cases/login-with-password.use-case.ts | 126 ++++++++++ ...esolve-authenticated-principal.use-case.ts | 52 ++++ .../domain/entities/account.entity.ts | 41 +++ .../domain/entities/credential.entity.ts | 25 ++ .../domain/entities/session.entity.ts | 84 +++++++ .../identity/domain/identity.errors.ts | 49 ++++ .../domain/repositories/account.repository.ts | 10 + .../repositories/credential.repository.ts | 8 + .../domain/repositories/session.repository.ts | 9 + .../services/credential-policy.service.ts | 16 ++ .../email-address.value-object.ts | 21 ++ src/modules/identity/identity.module.ts | 55 +++- .../http/create-account.request.ts | 16 ++ .../http/identity.controller.ts | 50 ++++ .../infrastructure/http/login.request.ts | 11 + .../persistence/pg-account.repository.ts | 106 ++++++++ .../persistence/pg-credential.repository.ts | 56 +++++ .../persistence/pg-session.repository.ts | 88 +++++++ .../security/argon2-password-hasher.ts | 20 ++ .../security/jwt-access-token.service.ts | 72 ++++++ .../contracts/users-identity.contract.ts | 19 ++ .../use-cases/create-user.use-case.ts | 28 +++ .../use-cases/get-user-by-id.use-case.ts | 25 ++ .../users/domain/entities/user.entity.ts | 45 ++++ .../domain/repositories/user.repository.ts | 8 + .../persistence/pg-user.repository.ts | 53 ++++ src/modules/users/users.module.ts | 46 +++- src/shared/domain/nexus.errors.ts | 20 ++ test/functional/health.e2e-spec.ts | 2 + test/functional/identity/identity.e2e-spec.ts | 97 +++++++ .../database.module.integration.spec.ts | 2 + .../identity/identity.integration.spec.ts | 108 ++++++++ .../bootstrap/config/load-app-config.spec.ts | 21 ++ .../logging/pino-logger.config.spec.ts | 4 + .../create-user-account.use-case.spec.ts | 104 ++++++++ .../invalidate-session.use-case.spec.ts | 77 ++++++ .../login-with-password.use-case.spec.ts | 170 +++++++++++++ .../domain/credential-policy.service.spec.ts | 16 ++ .../domain/email-address.value-object.spec.ts | 17 ++ .../identity/domain/session.entity.spec.ts | 39 +++ test/unit/modules/users/user.entity.spec.ts | 32 +++ 65 files changed, 2847 insertions(+), 107 deletions(-) create mode 100644 migrations/0001_core_identity.sql create mode 100644 src/bootstrap/errors/http-exception.filter.ts create mode 100644 src/bootstrap/persistence/database.executor.ts create mode 100644 src/bootstrap/persistence/migration.service.ts create mode 100644 src/bootstrap/persistence/migrations/run-migrations.ts create mode 100644 src/modules/identity/application/dto/authenticated-principal.dto.ts create mode 100644 src/modules/identity/application/ports/access-token.service.ts create mode 100644 src/modules/identity/application/ports/password-hasher.port.ts create mode 100644 src/modules/identity/application/use-cases/create-user-account.use-case.ts create mode 100644 src/modules/identity/application/use-cases/invalidate-session.use-case.ts create mode 100644 src/modules/identity/application/use-cases/login-with-password.use-case.ts create mode 100644 src/modules/identity/application/use-cases/resolve-authenticated-principal.use-case.ts create mode 100644 src/modules/identity/domain/entities/account.entity.ts create mode 100644 src/modules/identity/domain/entities/credential.entity.ts create mode 100644 src/modules/identity/domain/entities/session.entity.ts create mode 100644 src/modules/identity/domain/identity.errors.ts create mode 100644 src/modules/identity/domain/repositories/account.repository.ts create mode 100644 src/modules/identity/domain/repositories/credential.repository.ts create mode 100644 src/modules/identity/domain/repositories/session.repository.ts create mode 100644 src/modules/identity/domain/services/credential-policy.service.ts create mode 100644 src/modules/identity/domain/value-objects/email-address.value-object.ts create mode 100644 src/modules/identity/infrastructure/http/create-account.request.ts create mode 100644 src/modules/identity/infrastructure/http/identity.controller.ts create mode 100644 src/modules/identity/infrastructure/http/login.request.ts create mode 100644 src/modules/identity/infrastructure/persistence/pg-account.repository.ts create mode 100644 src/modules/identity/infrastructure/persistence/pg-credential.repository.ts create mode 100644 src/modules/identity/infrastructure/persistence/pg-session.repository.ts create mode 100644 src/modules/identity/infrastructure/security/argon2-password-hasher.ts create mode 100644 src/modules/identity/infrastructure/security/jwt-access-token.service.ts create mode 100644 src/modules/users/application/contracts/users-identity.contract.ts create mode 100644 src/modules/users/application/use-cases/create-user.use-case.ts create mode 100644 src/modules/users/application/use-cases/get-user-by-id.use-case.ts create mode 100644 src/modules/users/domain/entities/user.entity.ts create mode 100644 src/modules/users/domain/repositories/user.repository.ts create mode 100644 src/modules/users/infrastructure/persistence/pg-user.repository.ts create mode 100644 src/shared/domain/nexus.errors.ts create mode 100644 test/functional/identity/identity.e2e-spec.ts create mode 100644 test/integration/modules/identity/identity.integration.spec.ts create mode 100644 test/unit/modules/identity/application/create-user-account.use-case.spec.ts create mode 100644 test/unit/modules/identity/application/invalidate-session.use-case.spec.ts create mode 100644 test/unit/modules/identity/application/login-with-password.use-case.spec.ts create mode 100644 test/unit/modules/identity/domain/credential-policy.service.spec.ts create mode 100644 test/unit/modules/identity/domain/email-address.value-object.spec.ts create mode 100644 test/unit/modules/identity/domain/session.entity.spec.ts create mode 100644 test/unit/modules/users/user.entity.spec.ts diff --git a/.env.example b/.env.example index 64f41d8..78fca1b 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ APP_PORT=3000 +AUTH_JWT_SECRET=change-me-in-production +AUTH_JWT_EXPIRES_IN_MINUTES=480 DB_HOST=postgres DB_PORT=5432 DB_USER=postgres diff --git a/CHANGELOG.md b/CHANGELOG.md index 943e53b..faeda73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2026-03-25] +- feature: implementação da Phase 1 — Core Identity com criação de conta, login por email/senha e logout com sessão persistida. +- feature: módulo `users` mínimo, módulo `identity` funcional, JWT de transporte, hash Argon2id e migrations SQL automáticas. +- test: cobertura unitária do domínio e casos de uso, além de suites de integração e funcional para identity com Testcontainers. +- docs: README, arquitetura, comandos, handoff e status de fase atualizados para o novo fluxo. + ## [2026-03-25] - feature: bootstrap inicial do Nexus Platform com NestJS, TypeScript strict, health check e módulos placeholder. - feature: configuração centralizada por `.env`, logger estruturado com pino, base OpenTelemetry e conexão PostgreSQL sem ORM. diff --git a/README.md b/README.md index a820fb4..df72554 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Nexus Platform -Multi-tenant backend platform for identity, organizations, users, access control and immutable auditability. The codebase starts as a modular monolith and keeps DDD, Clean Architecture and internal event-driven boundaries explicit from day one. +Multi-tenant backend platform for identity, organizations, users, access control and immutable auditability. The codebase is intentionally evolving as a modular monolith with DDD, Clean Architecture and explicit internal boundaries. ## 🚧 Project Status -Current Phase: **Phase 0 — Foundation** +Current Phase: **Phase 1 — Core Identity** Notion reference: [Nexus Platform](https://www.notion.so/mrgomides/Nexus-Platform-32fe01f2262680cd9e32db2b5cdd8f7b?source=copy_link) @@ -14,19 +14,55 @@ Notion reference: [Nexus Platform](https://www.notion.so/mrgomides/Nexus-Platfor - TypeScript (`strict`) - NestJS - PostgreSQL +- `pg` with explicit repositories +- Argon2 +- JWT - Pino - OpenTelemetry - Docker - GitHub Actions -## Foundation Scope +## What Phase 1 Delivers -- HTTP bootstrap with `GET /health` -- centralized environment configuration -- PostgreSQL connectivity without schema or ORM -- structured logging with correlation id support -- OpenTelemetry provider bootstrap without exporters -- placeholder business modules with no domain logic yet +- minimal `users` ownership for internal user records +- `identity` domain with `Account`, `Credential` and `Session` +- account creation with unique global email +- login with email and password +- persisted and revocable sessions +- JWT as transport contract backed by persisted sessions +- SQL migrations applied automatically during bootstrap +- HTTP validation and standardized error handling +- structured security-aware logging for identity flows + +## Available Endpoints + +- `GET /health` +- `POST /identity/accounts` +- `POST /identity/login` +- `POST /identity/logout` + +## Login Flow + +```text +Create account + -> validate full name, email and password + -> create user + -> create account + -> hash password with Argon2id + -> persist credential + +Login + -> validate email + -> load account + user + credential + -> verify password hash + -> create persisted session + -> issue JWT with sub, aid, sid and jti + +Logout + -> verify JWT + -> load session + -> revoke persisted session +``` ## Project Structure @@ -34,25 +70,25 @@ Notion reference: [Nexus Platform](https://www.notion.so/mrgomides/Nexus-Platfor src/ bootstrap/ config/ + errors/ http/ logging/ persistence/ telemetry/ modules/ identity/ - organizations/ + application/ + domain/ + infrastructure/ users/ + application/ + domain/ + infrastructure/ + organizations/ access-control/ audit-logs/ shared/ - application/ domain/ - infrastructure/ - events/ - tenancy/ - auth/ - testing/ - jobs/ test/ unit/ integration/ @@ -67,6 +103,8 @@ Use `.env.example` as the baseline: ```dotenv APP_PORT=3000 +AUTH_JWT_SECRET=change-me-in-production +AUTH_JWT_EXPIRES_IN_MINUTES=480 DB_HOST=postgres DB_PORT=5432 DB_USER=postgres @@ -75,22 +113,24 @@ DB_NAME=nexus NODE_ENV=development ``` -## Run - -Local development: +## Run Locally ```bash cp .env.example .env npm install +npm run db:migrate npm run start:dev ``` -Docker: +The application also applies pending SQL migrations during bootstrap, so `npm run db:migrate` is the explicit operational option and app startup is the safety net. + +## Run with Docker ```bash cp .env.example .env docker compose up -d --build curl http://localhost:3000/health +docker compose logs -f app postgres docker compose down -v ``` @@ -108,9 +148,11 @@ npm run test:functional ## Architecture Notes -- Modular Monolith is the deployment model. -- DDD and Clean Architecture boundaries are preserved by keeping business modules isolated and pushing operational concerns into `src/bootstrap`. -- Multi-tenancy, authorization and auditability are treated as first-class constraints, but their business rules are intentionally deferred to later phases. +- Modular Monolith remains the deployment model. +- `users` owns the internal user record and exposes a narrow contract to `identity`. +- `identity` owns `accounts`, `credentials` and `sessions`. +- PostgreSQL access stays explicit through repositories and SQL, without ORM. +- Multi-tenancy, RBAC and full auditability remain mandatory next-phase constraints and are not implemented in Phase 1. ## Documentation diff --git a/docs/architecture.md b/docs/architecture.md index ecba966..74b64af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,31 +2,41 @@ ## Overview -Phase 0 establishes the operational shell of the Nexus Platform without introducing business logic. The repository is organized as a modular monolith with explicit domain boundaries and a clean split between bootstrap/infrastructure concerns and future domain/application code. +Phase 1 turns the foundation into a first functional slice of the platform. The repository remains a modular monolith, but now `users` and `identity` contain real domain/application/infrastructure code and a working authentication flow. ## C4-lite Diagram ```mermaid flowchart LR - user["Developer / CI"] --> app["Nexus Platform App\nNestJS + TypeScript"] - app --> health["Health Endpoint\nGET /health"] - app --> config["Configuration Layer\n.env + typed parser"] - app --> logging["Structured Logging\npino + correlation id"] - app --> telemetry["Telemetry Bootstrap\nOpenTelemetry SDK"] - app --> database["PostgreSQL\nconnectivity only"] - app --> modules["Business Modules\nidentity, organizations, users,\naccess-control, audit-logs"] + client["HTTP Client"] --> api["Nexus Platform API\nNestJS + TypeScript"] + api --> health["Health Endpoint\nGET /health"] + api --> identity["Identity Module\naccounts, credentials, sessions"] + identity --> users["Users Module\ninternal user contract"] + identity --> jwt["JWT Transport\nHS256"] + identity --> logs["Structured Logs\naccount/login/session events"] + api --> database["PostgreSQL\nSQL migrations + explicit repositories"] + api --> telemetry["OpenTelemetry Bootstrap"] ``` ## Module Boundaries -- `src/bootstrap`: app startup, HTTP entrypoints, config, logging, persistence and telemetry. -- `src/modules`: business modules reserved for domain/application/infrastructure growth without cross-module shortcuts. -- `src/shared`: shared technical and tactical building blocks that must not become a domain dumping ground. -- `src/jobs`: scheduled/background workloads reserved for future phases. +- `src/bootstrap`: startup, validation pipe, global error mapping, config, logging, migrations and database lifecycle. +- `src/modules/users`: owns the minimal internal user record consumed by `identity`. +- `src/modules/identity`: owns account creation, password hashing, login, session persistence, token issue and logout. +- `src/modules/organizations`, `src/modules/access-control`, `src/modules/audit-logs`: still placeholders for later phases. +- `src/shared`: shared technical/domain primitives that do not collapse module boundaries. -## Architectural Decisions Active in Phase 0 +## Active Decisions in Phase 1 -- Modular Monolith as the initial deployment model. -- PostgreSQL connectivity is established with `pg` directly, without ORM or schema ownership yet. -- No domain rules, authentication, authorization or tenant logic are implemented in the foundation. -- Multi-tenancy, deny-by-default authorization and immutable audit logs remain mandatory future constraints and must shape any new module contracts. +- PostgreSQL still uses `pg` directly with explicit repository implementations. +- SQL migrations are versioned in `migrations/` and applied automatically during bootstrap. +- Passwords are hashed with Argon2id and never persisted in clear text. +- JWT is only a transport token; revocation authority remains the persisted `sessions` table. +- Authentication errors stay generic at the HTTP boundary to avoid leaking account existence or status. + +## Phase 1 Constraints Preserved + +- No tenant resolution yet in the authentication flow. +- No RBAC enforcement yet. +- No full audit log append-only module yet. +- No external message bus or SSO/OIDC integration yet. diff --git a/docs/commands.md b/docs/commands.md index b8bab17..169d24e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,6 +5,7 @@ ```bash cp .env.example .env npm install +npm run db:migrate npm run start:dev npm run build npm run lint @@ -16,19 +17,3 @@ docker compose up -d --build docker compose down -v docker compose logs -f app postgres ``` - -## Make Shortcuts - -```bash -make up -make down -make build -make run -make logs -make test -make test-unit -make test-integration -make test-functional -make lint -make ci -``` diff --git a/docs/handoff.md b/docs/handoff.md index 595a6d8..4ceadc0 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,43 +1,37 @@ # Handoff ## Contexto -- Objetivo da tarefa: implementar a Fase 0 — Foundation do Nexus Platform. +- Objetivo da tarefa: implementar a Phase 1 — Core Identity do Nexus Platform. - Fase atual: concluída. -- Escopo atendido: scaffold NestJS, health endpoint, config `.env`, logging pino, telemetry base, PostgreSQL connectivity, placeholders modulares, Docker, CI e documentação operacional. - -## O que foi feito -- Estrutura base do repositório criada com `src/bootstrap`, `src/modules`, `src/shared`, `test`, `docs` e `migrations`. -- App NestJS configurada com health check, logger estruturado, bootstrap de telemetry e serviço de conexão PostgreSQL sem ORM. -- Testes unitários escritos e executados; testes de integração e funcionais implementados com Testcontainers. -- Dockerfile, `docker-compose.yml`, Makefile, workflow de CI, README, changelog e docs de apoio adicionados. +- Escopo atendido: criação de conta, login com email/senha, sessão persistida/revogável, migrations SQL, validação HTTP, erros padronizados, logs estruturados e atualização documental. ## Arquivos alterados -- `package.json`, `package-lock.json`, configs TS/Jest/ESLint e `.env.example` -- `src/**` +- `package.json`, `package-lock.json`, `.env.example` +- `src/bootstrap/**` +- `src/modules/users/**` +- `src/modules/identity/**` - `test/**` -- `Dockerfile`, `docker-compose.yml`, `Makefile`, `.github/workflows/ci.yml` -- `README.md`, `CHANGELOG.md`, `docs/**`, `.agents/decisions/0004-foundation-pg-without-orm.md` +- `migrations/0001_core_identity.sql` +- `README.md`, `CHANGELOG.md`, `docs/architecture.md`, `docs/commands.md`, `docs/phase-status.md`, `docs/handoff.md` ## Decisões tomadas -- `pg` direto foi escolhido para validar conectividade com PostgreSQL sem fixar um ORM prematuramente. -- logging foi centralizado com `nestjs-pino` e correlation id por request. -- telemetry foi inicializada com `NodeSDK` sem exporters, apenas para preparar a borda observável. +- `users` é o dono do registro mínimo de usuário e exporta contrato explícito para `identity`. +- `identity` é o dono de `accounts`, `credentials` e `sessions`. +- JWT foi mantido como contrato de transporte; revogação depende da sessão persistida. +- migrations continuam em SQL explícito com `pg`, sem ORM. -## Testes +## Testes executados - Unit: `npm run test:unit` executado com sucesso. +- Build: `npm run build` executado com sucesso. - Integration: implementado com Testcontainers; nesta execução local ficou pulado por indisponibilidade do daemon Docker. - Functional: implementado com Testcontainers; nesta execução local ficou pulado por indisponibilidade do daemon Docker. -## Impactos avaliados -- Tenant: nenhuma regra implementada; fundação preserva espaço explícito para `tenant_id` e scoping futuro. -- RBAC: nenhuma decisão de autorização implementada; módulos permanecem vazios. -- Audit logs: módulo placeholder criado, sem persistência ou trilha ainda. -- Observability: logging e bootstrap de telemetry adicionados na borda. - ## Riscos e pendências -- Validar `docker compose up -d --build` e smoke test `GET /health` em ambiente com daemon Docker ativo. -- Escolha futura de ORM/migrations continua em aberto e deve passar por ADR quando houver modelo de domínio real. - -## Próximos passos recomendados -1. Executar a validação Docker localmente com daemon ativo e confirmar `/health` e conexão PostgreSQL via compose. -2. Iniciar a próxima fase mantendo tenant, autorização e auditoria como restrições centrais desde os primeiros contratos de módulo. +- Validar suites de integração e funcional em ambiente local ou CI com Docker ativo. +- Tenant context, memberships, RBAC e auditabilidade completa continuam pendentes para próximas fases. +- Revisar secret management para produção antes de qualquer deploy real. + +## Próximos passos +1. Implementar Phase 2 com `organizations`, `memberships` e resolução explícita de tenant. +2. Acoplar autenticação ao contexto ativo de tenant sem quebrar o contrato atual de sessão. +3. Introduzir RBAC e auditoria completa em cima do principal autenticado já existente. diff --git a/docs/phase-status.md b/docs/phase-status.md index 1533989..9d9b122 100644 --- a/docs/phase-status.md +++ b/docs/phase-status.md @@ -1,27 +1,31 @@ # Phase Status ## Fase atual -- Nome: Phase 0 — Foundation +- Nome: Phase 1 — Core Identity - Status: done - Responsável: Codex - Data: 2026-03-25 ## Objetivo da fase -- Estabelecer a base operacional do Nexus Platform com NestJS, TypeScript strict, PostgreSQL, observabilidade mínima, Docker e CI. +- Validar a arquitetura modular com um fluxo funcional real de criação de conta, autenticação por email/senha e invalidação de sessão. ## Entradas - `AGENTS.md` e regras em `.agents/rules/*` -- contexto do produto, mapa de módulos e ADRs 0001, 0002, 0003 +- contexto do produto, mapa de módulos e ADRs 0001, 0002, 0003 e 0004 +- página `Nexus Platform` no Notion -## Saídas esperadas -- aplicação subindo com `GET /health` -- conexão PostgreSQL validada -- estrutura modular criada -- documentação operacional atualizada +## Saídas entregues +- módulo `users` com contrato interno mínimo +- módulo `identity` com domínio, casos de uso, controllers e persistência +- migrations SQL para `users`, `accounts`, `credentials` e `sessions` +- validação HTTP global e tratamento padronizado de erro +- logs estruturados para `account_created`, `login_succeeded`, `login_failed` e `session_invalidated` +- documentação operacional atualizada para a fase ## Bloqueios -- daemon Docker indisponível no ambiente local durante a validação desta execução +- daemon Docker indisponível no ambiente local durante a validação desta execução, então suites de integração e funcional permaneceram puladas localmente ## Decisões / observações -- `pg` foi adotado como adapter de conexão para evitar antecipar a escolha de ORM. -- suites de integração e funcional usam Testcontainers e rodam integralmente quando Docker está disponível; localmente elas são puladas se o daemon estiver indisponível. +- `users` passou a ser o dono do registro mínimo de usuário e `identity` consome apenas o contrato interno exportado. +- JWT foi adotado apenas como contrato de transporte; a revogação real continua sendo determinada pela tabela `sessions`. +- multi-tenancy, RBAC e auditoria completa permanecem fora do escopo desta fase. diff --git a/migrations/0001_core_identity.sql b/migrations/0001_core_identity.sql new file mode 100644 index 0000000..4e5b02e --- /dev/null +++ b/migrations/0001_core_identity.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + full_name VARCHAR(120) NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + email VARCHAR(320) NOT NULL, + normalized_email VARCHAR(320) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS credentials ( + account_id UUID PRIMARY KEY REFERENCES accounts(id), + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id), + user_id UUID NOT NULL REFERENCES users(id), + jti UUID NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts (user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_account_id ON sessions (account_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id); diff --git a/package-lock.json b/package-lock.json index ae951f1..7c71f24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,11 @@ "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.40.0", + "argon2": "^0.44.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "dotenv": "^17.3.1", + "jsonwebtoken": "^9.0.3", "nestjs-pino": "^4.6.1", "pg": "^8.20.0", "pino": "^10.3.1", @@ -31,6 +35,7 @@ "@testcontainers/postgresql": "^11.13.0", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", @@ -771,6 +776,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2890,6 +2901,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -3292,6 +3312,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -3299,6 +3330,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -3424,6 +3462,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4582,6 +4626,22 @@ "dev": true, "license": "MIT" }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5050,6 +5110,12 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5256,6 +5322,25 @@ "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT", + "peer": true + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -5719,11 +5804,27 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5957,6 +6058,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7346,7 +7456,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -8316,6 +8425,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8396,6 +8548,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.40", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.40.tgz", + "integrity": "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8465,6 +8623,42 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8472,6 +8666,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8843,6 +9043,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -8853,6 +9062,17 @@ "lodash": "^4.17.21" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9113,7 +9333,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9989,7 +10208,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10078,7 +10296,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10091,7 +10308,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11429,6 +11645,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -11608,7 +11833,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index c74940d..30f3354 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "main": "dist/main.js", "scripts": { "build": "nest build", + "db:migrate": "ts-node src/bootstrap/persistence/migrations/run-migrations.ts", "start": "node dist/main.js", "start:dev": "nest start --watch", "start:prod": "node dist/main.js", @@ -40,7 +41,11 @@ "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.40.0", + "argon2": "^0.44.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "dotenv": "^17.3.1", + "jsonwebtoken": "^9.0.3", "nestjs-pino": "^4.6.1", "pg": "^8.20.0", "pino": "^10.3.1", @@ -55,6 +60,7 @@ "@testcontainers/postgresql": "^11.13.0", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", diff --git a/src/bootstrap/application.factory.ts b/src/bootstrap/application.factory.ts index a98d240..bd0748b 100644 --- a/src/bootstrap/application.factory.ts +++ b/src/bootstrap/application.factory.ts @@ -1,8 +1,10 @@ -import type { INestApplication } from "@nestjs/common"; +import { ValidationPipe, type INestApplication } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { Logger } from "nestjs-pino"; +import { PinoLogger } from "nestjs-pino"; import { AppModule } from "../app.module"; +import { GlobalExceptionFilter } from "./errors/http-exception.filter"; import { shutdownTelemetry } from "./telemetry/telemetry.sdk"; export async function createApplication(): Promise { @@ -12,6 +14,14 @@ export async function createApplication(): Promise { application.useLogger(application.get(Logger)); application.enableShutdownHooks(); + application.useGlobalPipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true, + }), + ); + application.useGlobalFilters(new GlobalExceptionFilter(application.get(PinoLogger))); return application; } diff --git a/src/bootstrap/config/app-config.ts b/src/bootstrap/config/app-config.ts index 0ef8eda..5ef99ef 100644 --- a/src/bootstrap/config/app-config.ts +++ b/src/bootstrap/config/app-config.ts @@ -9,6 +9,10 @@ export interface AppConfig { readonly port: number; readonly nodeEnv: NodeEnvironment; }; + readonly auth: { + readonly jwtExpiresInMinutes: number; + readonly jwtSecret: string; + }; readonly database: { readonly host: string; readonly port: number; @@ -35,6 +39,14 @@ export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): App nodeEnv: readNodeEnvironment(environment.NODE_ENV), port: readRequiredNumber(environment.APP_PORT, "APP_PORT"), }, + auth: { + jwtExpiresInMinutes: readOptionalNumber( + environment.AUTH_JWT_EXPIRES_IN_MINUTES, + "AUTH_JWT_EXPIRES_IN_MINUTES", + 480, + ), + jwtSecret: readRequiredString(environment.AUTH_JWT_SECRET, "AUTH_JWT_SECRET"), + }, database: { host: readRequiredString(environment.DB_HOST, "DB_HOST"), name: readRequiredString(environment.DB_NAME, "DB_NAME"), @@ -76,3 +88,11 @@ function readRequiredNumber(value: string | undefined, key: string): number { return parsedValue; } + +function readOptionalNumber(value: string | undefined, key: string, fallback: number): number { + if (value === undefined || value.trim().length === 0) { + return fallback; + } + + return readRequiredNumber(value, key); +} diff --git a/src/bootstrap/config/application-config.service.ts b/src/bootstrap/config/application-config.service.ts index 9029f57..cc5e80e 100644 --- a/src/bootstrap/config/application-config.service.ts +++ b/src/bootstrap/config/application-config.service.ts @@ -10,6 +10,10 @@ export class ApplicationConfigService { return this.config.app.port; } + public get auth(): AppConfig["auth"] { + return this.config.auth; + } + public get nodeEnv(): AppConfig["app"]["nodeEnv"] { return this.config.app.nodeEnv; } diff --git a/src/bootstrap/errors/http-exception.filter.ts b/src/bootstrap/errors/http-exception.filter.ts new file mode 100644 index 0000000..9925f61 --- /dev/null +++ b/src/bootstrap/errors/http-exception.filter.ts @@ -0,0 +1,186 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; +import type { Request, Response } from "express"; + +import { + AuthenticationError, + AuthorizationError, + ConflictError, + NexusError, + NotFoundError, + ValidationError, +} from "../../shared/domain/nexus.errors"; + +interface ErrorResponseBody { + readonly statusCode: number; + readonly error: string; + readonly message: string | string[]; +} + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + public constructor(private readonly logger: PinoLogger) { + this.logger.setContext(GlobalExceptionFilter.name); + } + + public catch(exception: unknown, host: ArgumentsHost): void { + const context = host.switchToHttp(); + const request = context.getRequest(); + const response = context.getResponse(); + const normalizedError = this.normalizeError(exception); + + this.logFailure(request, normalizedError, exception); + response.status(normalizedError.statusCode).json(normalizedError.body); + } + + private normalizeError(exception: unknown): { + readonly body: ErrorResponseBody; + readonly code: string; + readonly statusCode: number; + } { + if (exception instanceof HttpException) { + return this.normalizeHttpException(exception); + } + + if (exception instanceof ValidationError) { + return this.buildNexusErrorResponse(exception, HttpStatus.BAD_REQUEST, "Bad Request"); + } + + if (exception instanceof ConflictError) { + return this.buildNexusErrorResponse(exception, HttpStatus.CONFLICT, "Conflict"); + } + + if (exception instanceof AuthenticationError) { + return this.buildNexusErrorResponse(exception, HttpStatus.UNAUTHORIZED, "Unauthorized"); + } + + if (exception instanceof AuthorizationError) { + return this.buildNexusErrorResponse(exception, HttpStatus.FORBIDDEN, "Forbidden"); + } + + if (exception instanceof NotFoundError) { + return this.buildNexusErrorResponse(exception, HttpStatus.NOT_FOUND, "Not Found"); + } + + return { + body: { + error: "Internal Server Error", + message: "Internal server error", + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + }, + code: "internal_error", + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + }; + } + + private normalizeHttpException(exception: HttpException): { + readonly body: ErrorResponseBody; + readonly code: string; + readonly statusCode: number; + } { + const statusCode = exception.getStatus(); + const response = exception.getResponse(); + + if (typeof response === "string") { + return { + body: { + error: this.resolveHttpStatusTitle(statusCode), + message: response, + statusCode, + }, + code: "http_exception", + statusCode, + }; + } + + const responseBody = response as Record; + + return { + body: { + error: this.readString(responseBody.error) ?? this.resolveHttpStatusTitle(statusCode), + message: this.readMessage(responseBody.message), + statusCode, + }, + code: "http_exception", + statusCode, + }; + } + + private buildNexusErrorResponse( + exception: NexusError, + statusCode: number, + error: string, + ): { + readonly body: ErrorResponseBody; + readonly code: string; + readonly statusCode: number; + } { + return { + body: { + error, + message: exception.publicMessage, + statusCode, + }, + code: exception.code, + statusCode, + }; + } + + private logFailure( + request: Request & { id?: string | number }, + normalizedError: { readonly code: string; readonly statusCode: number }, + exception: unknown, + ): void { + const message = "HTTP request failed"; + const logPayload = { + correlationId: + typeof request.id === "number" || typeof request.id === "string" + ? request.id.toString() + : "unknown", + errorCode: normalizedError.code, + event: normalizedError.statusCode >= 500 ? "error" : "alert", + method: request.method, + path: request.url, + statusCode: normalizedError.statusCode, + }; + + if (normalizedError.statusCode >= 500) { + this.logger.error({ ...logPayload, err: exception }, message); + return; + } + + this.logger.warn(logPayload, message); + } + + private readMessage(value: unknown): string | string[] { + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + + if (typeof value === "string") { + return value; + } + + return "Unexpected request error"; + } + + private readString(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) { + return undefined; + } + + return value; + } + + private resolveHttpStatusTitle(statusCode: number): string { + return ( + { + [HttpStatus.BAD_REQUEST]: "Bad Request", + [HttpStatus.UNAUTHORIZED]: "Unauthorized", + [HttpStatus.FORBIDDEN]: "Forbidden", + [HttpStatus.NOT_FOUND]: "Not Found", + [HttpStatus.CONFLICT]: "Conflict", + }[statusCode] ?? "Error" + ); + } +} diff --git a/src/bootstrap/persistence/database.executor.ts b/src/bootstrap/persistence/database.executor.ts new file mode 100644 index 0000000..3aef816 --- /dev/null +++ b/src/bootstrap/persistence/database.executor.ts @@ -0,0 +1,48 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { Inject, Injectable } from "@nestjs/common"; +import type { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; + +import { DATABASE_POOL } from "./database.constants"; + +@Injectable() +export class DatabaseExecutor { + private readonly transactionStorage = new AsyncLocalStorage(); + + public constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {} + + public async query( + statement: string, + values: readonly unknown[] = [], + ): Promise> { + const client = this.transactionStorage.getStore(); + const executor = client ?? this.pool; + + return executor.query(statement, [...values]); + } + + public async withTransaction(operation: () => Promise): Promise { + const activeClient = this.transactionStorage.getStore(); + + if (activeClient !== undefined) { + return operation(); + } + + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const result = await this.transactionStorage.run(client, operation); + + await client.query("COMMIT"); + + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} diff --git a/src/bootstrap/persistence/database.module.ts b/src/bootstrap/persistence/database.module.ts index cca3181..208c6c4 100644 --- a/src/bootstrap/persistence/database.module.ts +++ b/src/bootstrap/persistence/database.module.ts @@ -3,7 +3,9 @@ import { Global, Module } from "@nestjs/common"; import { APP_CONFIG, type AppConfig } from "../config/app-config"; import { AppConfigModule } from "../config/app-config.module"; import { DATABASE_POOL } from "./database.constants"; +import { DatabaseExecutor } from "./database.executor"; import { createDatabasePool } from "./database.providers"; +import { DatabaseMigrationService } from "./migration.service"; import { DatabaseConnectionService } from "./database.service"; @Global() @@ -15,8 +17,10 @@ import { DatabaseConnectionService } from "./database.service"; inject: [APP_CONFIG], useFactory: (config: AppConfig) => createDatabasePool(config), }, + DatabaseExecutor, + DatabaseMigrationService, DatabaseConnectionService, ], - exports: [DATABASE_POOL], + exports: [DATABASE_POOL, DatabaseExecutor], }) export class DatabaseModule {} diff --git a/src/bootstrap/persistence/database.service.ts b/src/bootstrap/persistence/database.service.ts index 55a167a..b5364ca 100644 --- a/src/bootstrap/persistence/database.service.ts +++ b/src/bootstrap/persistence/database.service.ts @@ -3,11 +3,13 @@ import { PinoLogger } from "nestjs-pino"; import type { Pool } from "pg"; import { DATABASE_POOL } from "./database.constants"; +import { DatabaseMigrationService } from "./migration.service"; @Injectable() export class DatabaseConnectionService implements OnApplicationBootstrap, OnApplicationShutdown { public constructor( @Inject(DATABASE_POOL) private readonly pool: Pool, + private readonly migrationService: DatabaseMigrationService, private readonly logger: PinoLogger, ) { this.logger.setContext(DatabaseConnectionService.name); @@ -17,6 +19,7 @@ export class DatabaseConnectionService implements OnApplicationBootstrap, OnAppl this.logger.info({ event: "start" }, "Validating PostgreSQL connectivity"); try { + await this.migrationService.runPendingMigrations(); await this.pool.query("SELECT 1"); this.logger.info({ event: "success" }, "PostgreSQL connectivity validated"); } catch (error) { diff --git a/src/bootstrap/persistence/migration.service.ts b/src/bootstrap/persistence/migration.service.ts new file mode 100644 index 0000000..2142e34 --- /dev/null +++ b/src/bootstrap/persistence/migration.service.ts @@ -0,0 +1,96 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Inject, Injectable } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; +import type { Pool } from "pg"; + +import { DATABASE_POOL } from "./database.constants"; + +interface AppliedMigrationRow { + readonly version: string; +} + +@Injectable() +export class DatabaseMigrationService { + public constructor( + @Inject(DATABASE_POOL) private readonly pool: Pool, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(DatabaseMigrationService.name); + } + + public async runPendingMigrations(): Promise { + this.logger.info({ event: "start" }, "Checking pending PostgreSQL migrations"); + await this.ensureMigrationsTable(); + + const migrationDirectory = join(process.cwd(), "migrations"); + const migrationFiles = await this.readMigrationFiles(migrationDirectory); + + if (migrationFiles.length === 0) { + this.logger.info({ event: "success" }, "No SQL migrations found"); + return; + } + + const appliedVersions = await this.loadAppliedVersions(); + + for (const version of migrationFiles) { + if (appliedVersions.has(version)) { + continue; + } + + await this.applyMigration(migrationDirectory, version); + } + + this.logger.info({ event: "success" }, "PostgreSQL migrations are up to date"); + } + + private async ensureMigrationsTable(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL + ) + `); + } + + private async loadAppliedVersions(): Promise> { + const result = await this.pool.query( + "SELECT version FROM schema_migrations ORDER BY version ASC", + ); + + return new Set(result.rows.map((row) => row.version)); + } + + private async readMigrationFiles(migrationDirectory: string): Promise { + const files = await readdir(migrationDirectory, { withFileTypes: true }); + + return files + .filter((file) => file.isFile() && file.name.endsWith(".sql")) + .map((file) => file.name) + .sort((left, right) => left.localeCompare(right)); + } + + private async applyMigration(migrationDirectory: string, version: string): Promise { + const statement = await readFile(join(migrationDirectory, version), "utf8"); + const client = await this.pool.connect(); + + this.logger.info({ event: "start", migration: version }, "Applying PostgreSQL migration"); + + try { + await client.query("BEGIN"); + await client.query(statement); + await client.query("INSERT INTO schema_migrations (version, applied_at) VALUES ($1, NOW())", [ + version, + ]); + await client.query("COMMIT"); + this.logger.info({ event: "success", migration: version }, "PostgreSQL migration applied"); + } catch (error) { + await client.query("ROLLBACK"); + this.logger.error({ err: error, event: "error", migration: version }, "Migration failed"); + throw error; + } finally { + client.release(); + } + } +} diff --git a/src/bootstrap/persistence/migrations/run-migrations.ts b/src/bootstrap/persistence/migrations/run-migrations.ts new file mode 100644 index 0000000..034391b --- /dev/null +++ b/src/bootstrap/persistence/migrations/run-migrations.ts @@ -0,0 +1,31 @@ +import { PinoLogger } from "nestjs-pino"; + +import { createDatabasePool } from "../database.providers"; +import { loadAppConfig, loadEnvironmentVariables } from "../../config/app-config"; +import { DatabaseMigrationService } from "../migration.service"; + +async function runMigrations(): Promise { + loadEnvironmentVariables(); + + const config = loadAppConfig(); + const pool = createDatabasePool(config); + const logger = new PinoLogger({ + pinoHttp: { + base: null, + enabled: true, + level: "info", + messageKey: "message", + timestamp: () => `,"timestamp":"${new Date().toISOString()}"`, + }, + }); + + try { + const migrationService = new DatabaseMigrationService(pool, logger); + + await migrationService.runPendingMigrations(); + } finally { + await pool.end(); + } +} + +void runMigrations(); diff --git a/src/modules/identity/application/dto/authenticated-principal.dto.ts b/src/modules/identity/application/dto/authenticated-principal.dto.ts new file mode 100644 index 0000000..5460ab2 --- /dev/null +++ b/src/modules/identity/application/dto/authenticated-principal.dto.ts @@ -0,0 +1,11 @@ +import type { AccountStatus } from "../../domain/entities/account.entity"; +import type { UserStatus } from "../../../users/domain/entities/user.entity"; + +export interface AuthenticatedPrincipalDto { + readonly accountId: string; + readonly email: string; + readonly sessionId: string; + readonly userId: string; + readonly accountStatus: AccountStatus; + readonly userStatus: UserStatus; +} diff --git a/src/modules/identity/application/ports/access-token.service.ts b/src/modules/identity/application/ports/access-token.service.ts new file mode 100644 index 0000000..19968cb --- /dev/null +++ b/src/modules/identity/application/ports/access-token.service.ts @@ -0,0 +1,13 @@ +export const ACCESS_TOKEN_SERVICE = Symbol("ACCESS_TOKEN_SERVICE"); + +export interface AccessTokenPayload { + readonly aid: string; + readonly jti: string; + readonly sid: string; + readonly sub: string; +} + +export interface AccessTokenService { + issue(payload: AccessTokenPayload, expiresAt: Date): Promise; + verify(token: string): Promise; +} diff --git a/src/modules/identity/application/ports/password-hasher.port.ts b/src/modules/identity/application/ports/password-hasher.port.ts new file mode 100644 index 0000000..ce1ca7e --- /dev/null +++ b/src/modules/identity/application/ports/password-hasher.port.ts @@ -0,0 +1,6 @@ +export const PASSWORD_HASHER = Symbol("PASSWORD_HASHER"); + +export interface PasswordHasher { + hash(password: string): Promise; + verify(hash: string, password: string): Promise; +} diff --git a/src/modules/identity/application/use-cases/create-user-account.use-case.ts b/src/modules/identity/application/use-cases/create-user-account.use-case.ts new file mode 100644 index 0000000..086c9d6 --- /dev/null +++ b/src/modules/identity/application/use-cases/create-user-account.use-case.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; + +import { Inject, Injectable } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; + +import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { + USERS_IDENTITY_CONTRACT, + type UsersIdentityContract, +} from "../../../users/application/contracts/users-identity.contract"; +import { Account } from "../../domain/entities/account.entity"; +import { Credential } from "../../domain/entities/credential.entity"; +import { DuplicateAccountEmailError } from "../../domain/identity.errors"; +import { ACCOUNT_REPOSITORY, type AccountRepository } from "../../domain/repositories/account.repository"; +import { + CREDENTIAL_REPOSITORY, + type CredentialRepository, +} from "../../domain/repositories/credential.repository"; +import { CredentialPolicyService } from "../../domain/services/credential-policy.service"; +import { EmailAddress } from "../../domain/value-objects/email-address.value-object"; +import { PASSWORD_HASHER, type PasswordHasher } from "../ports/password-hasher.port"; + +export interface CreateUserAccountInput { + readonly email: string; + readonly fullName: string; + readonly password: string; +} + +export interface CreateUserAccountResult { + readonly accountId: string; + readonly email: string; + readonly status: "active"; + readonly userId: string; +} + +@Injectable() +export class CreateUserAccountUseCase { + public constructor( + @Inject(ACCOUNT_REPOSITORY) private readonly accountRepository: AccountRepository, + @Inject(CREDENTIAL_REPOSITORY) private readonly credentialRepository: CredentialRepository, + @Inject(PASSWORD_HASHER) private readonly passwordHasher: PasswordHasher, + @Inject(USERS_IDENTITY_CONTRACT) + private readonly usersIdentityContract: UsersIdentityContract, + private readonly credentialPolicy: CredentialPolicyService, + private readonly databaseExecutor: DatabaseExecutor, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(CreateUserAccountUseCase.name); + } + + public async execute(input: CreateUserAccountInput): Promise { + const email = EmailAddress.create(input.email); + + this.credentialPolicy.ensurePasswordIsAllowed(input.password); + + const existingAccount = await this.accountRepository.findByEmail(email); + + if (existingAccount !== null) { + throw new DuplicateAccountEmailError(); + } + + const userId = randomUUID(); + const accountId = randomUUID(); + const now = new Date(); + + await this.databaseExecutor.withTransaction(async () => { + await this.usersIdentityContract.createUser({ + fullName: input.fullName, + userId, + }); + + const account = Account.create({ + email, + id: accountId, + now, + userId, + }); + + const passwordHash = await this.passwordHasher.hash(input.password); + const credential = Credential.create({ + accountId, + now, + passwordHash, + }); + + await this.accountRepository.save(account); + await this.credentialRepository.save(credential); + }); + + this.logger.info( + { + accountId, + event: "account_created", + userId, + }, + "Identity account created", + ); + + return { + accountId, + email: email.normalized, + status: "active", + userId, + }; + } +} diff --git a/src/modules/identity/application/use-cases/invalidate-session.use-case.ts b/src/modules/identity/application/use-cases/invalidate-session.use-case.ts new file mode 100644 index 0000000..4747f10 --- /dev/null +++ b/src/modules/identity/application/use-cases/invalidate-session.use-case.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; + +import { InvalidAccessTokenError } from "../../domain/identity.errors"; +import { SESSION_REPOSITORY, type SessionRepository } from "../../domain/repositories/session.repository"; +import { ACCESS_TOKEN_SERVICE, type AccessTokenService } from "../ports/access-token.service"; + +@Injectable() +export class InvalidateSessionUseCase { + public constructor( + @Inject(SESSION_REPOSITORY) private readonly sessionRepository: SessionRepository, + @Inject(ACCESS_TOKEN_SERVICE) private readonly accessTokenService: AccessTokenService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(InvalidateSessionUseCase.name); + } + + public async execute(accessToken: string): Promise { + const payload = await this.accessTokenService.verify(accessToken); + const session = await this.sessionRepository.findById(payload.sid); + + if ( + session === null || + session.status !== "active" || + session.jti !== payload.jti || + session.isExpired(new Date()) + ) { + throw new InvalidAccessTokenError(); + } + + const revokedSession = session.revoke(new Date()); + + await this.sessionRepository.update(revokedSession); + + this.logger.info( + { + accountId: revokedSession.accountId, + event: "session_invalidated", + sessionId: revokedSession.id, + userId: revokedSession.userId, + }, + "Identity session invalidated", + ); + } +} diff --git a/src/modules/identity/application/use-cases/login-with-password.use-case.ts b/src/modules/identity/application/use-cases/login-with-password.use-case.ts new file mode 100644 index 0000000..55f424d --- /dev/null +++ b/src/modules/identity/application/use-cases/login-with-password.use-case.ts @@ -0,0 +1,126 @@ +import { randomUUID } from "node:crypto"; + +import { Inject, Injectable } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; + +import { ApplicationConfigService } from "../../../../bootstrap/config/application-config.service"; +import { + USERS_IDENTITY_CONTRACT, + type UsersIdentityContract, +} from "../../../users/application/contracts/users-identity.contract"; +import { Session } from "../../domain/entities/session.entity"; +import { InvalidCredentialsError } from "../../domain/identity.errors"; +import { ACCOUNT_REPOSITORY, type AccountRepository } from "../../domain/repositories/account.repository"; +import { + CREDENTIAL_REPOSITORY, + type CredentialRepository, +} from "../../domain/repositories/credential.repository"; +import { SESSION_REPOSITORY, type SessionRepository } from "../../domain/repositories/session.repository"; +import { EmailAddress } from "../../domain/value-objects/email-address.value-object"; +import type { AuthenticatedPrincipalDto } from "../dto/authenticated-principal.dto"; +import { ACCESS_TOKEN_SERVICE, type AccessTokenService } from "../ports/access-token.service"; +import { PASSWORD_HASHER, type PasswordHasher } from "../ports/password-hasher.port"; + +export interface LoginWithPasswordInput { + readonly email: string; + readonly password: string; +} + +export interface LoginWithPasswordResult { + readonly accessToken: string; + readonly principal: Omit; + readonly sessionId: string; + readonly tokenType: "Bearer"; +} + +@Injectable() +export class LoginWithPasswordUseCase { + public constructor( + @Inject(ACCOUNT_REPOSITORY) private readonly accountRepository: AccountRepository, + @Inject(CREDENTIAL_REPOSITORY) private readonly credentialRepository: CredentialRepository, + @Inject(SESSION_REPOSITORY) private readonly sessionRepository: SessionRepository, + @Inject(PASSWORD_HASHER) private readonly passwordHasher: PasswordHasher, + @Inject(ACCESS_TOKEN_SERVICE) private readonly accessTokenService: AccessTokenService, + @Inject(USERS_IDENTITY_CONTRACT) + private readonly usersIdentityContract: UsersIdentityContract, + private readonly configuration: ApplicationConfigService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(LoginWithPasswordUseCase.name); + } + + public async execute(input: LoginWithPasswordInput): Promise { + const email = EmailAddress.create(input.email); + const account = await this.accountRepository.findByEmail(email); + + if (account === null || account.status !== "active") { + return this.failAuthentication(); + } + + const user = await this.usersIdentityContract.getUserById(account.userId); + const credential = await this.credentialRepository.findByAccountId(account.id); + + if (user === null || user.status !== "active" || credential === null) { + return this.failAuthentication(); + } + + const passwordMatches = await this.passwordHasher.verify(credential.passwordHash, input.password); + + if (!passwordMatches) { + return this.failAuthentication(); + } + + const now = new Date(); + const expiresAt = new Date( + now.getTime() + 1000 * 60 * this.configuration.auth.jwtExpiresInMinutes, + ); + const session = Session.start({ + accountId: account.id, + expiresAt, + id: randomUUID(), + jti: randomUUID(), + now, + userId: user.userId, + }); + + await this.sessionRepository.save(session); + + const accessToken = await this.accessTokenService.issue( + { + aid: account.id, + jti: session.jti, + sid: session.id, + sub: user.userId, + }, + expiresAt, + ); + + this.logger.info( + { + accountId: account.id, + event: "login_succeeded", + sessionId: session.id, + userId: user.userId, + }, + "Identity login succeeded", + ); + + return { + accessToken, + principal: { + accountId: account.id, + accountStatus: account.status, + email: account.email.normalized, + userId: user.userId, + userStatus: user.status, + }, + sessionId: session.id, + tokenType: "Bearer", + }; + } + + private failAuthentication(): never { + this.logger.warn({ event: "login_failed" }, "Identity login failed"); + throw new InvalidCredentialsError(); + } +} diff --git a/src/modules/identity/application/use-cases/resolve-authenticated-principal.use-case.ts b/src/modules/identity/application/use-cases/resolve-authenticated-principal.use-case.ts new file mode 100644 index 0000000..f340450 --- /dev/null +++ b/src/modules/identity/application/use-cases/resolve-authenticated-principal.use-case.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { + USERS_IDENTITY_CONTRACT, + type UsersIdentityContract, +} from "../../../users/application/contracts/users-identity.contract"; +import { InvalidCredentialsError, InvalidAccessTokenError } from "../../domain/identity.errors"; +import { ACCOUNT_REPOSITORY, type AccountRepository } from "../../domain/repositories/account.repository"; +import { SESSION_REPOSITORY, type SessionRepository } from "../../domain/repositories/session.repository"; +import type { AuthenticatedPrincipalDto } from "../dto/authenticated-principal.dto"; +import { ACCESS_TOKEN_SERVICE, type AccessTokenService } from "../ports/access-token.service"; + +@Injectable() +export class ResolveAuthenticatedPrincipalUseCase { + public constructor( + @Inject(SESSION_REPOSITORY) private readonly sessionRepository: SessionRepository, + @Inject(ACCOUNT_REPOSITORY) private readonly accountRepository: AccountRepository, + @Inject(ACCESS_TOKEN_SERVICE) private readonly accessTokenService: AccessTokenService, + @Inject(USERS_IDENTITY_CONTRACT) + private readonly usersIdentityContract: UsersIdentityContract, + ) {} + + public async execute(accessToken: string): Promise { + const payload = await this.accessTokenService.verify(accessToken); + const session = await this.sessionRepository.findById(payload.sid); + + if ( + session === null || + session.status !== "active" || + session.jti !== payload.jti || + session.isExpired(new Date()) + ) { + throw new InvalidAccessTokenError(); + } + + const account = await this.accountRepository.findById(session.accountId); + const user = await this.usersIdentityContract.getUserById(session.userId); + + if (account === null || user === null || account.status !== "active" || user.status !== "active") { + throw new InvalidCredentialsError(); + } + + return { + accountId: account.id, + accountStatus: account.status, + email: account.email.normalized, + sessionId: session.id, + userId: user.userId, + userStatus: user.status, + }; + } +} diff --git a/src/modules/identity/domain/entities/account.entity.ts b/src/modules/identity/domain/entities/account.entity.ts new file mode 100644 index 0000000..b0f3d5f --- /dev/null +++ b/src/modules/identity/domain/entities/account.entity.ts @@ -0,0 +1,41 @@ +import type { EmailAddress } from "../value-objects/email-address.value-object"; + +export type AccountStatus = "active" | "inactive"; + +export class Account { + public static create(props: { + readonly email: EmailAddress; + readonly id: string; + readonly now: Date; + readonly userId: string; + }): Account { + return new Account(props.id, props.userId, props.email, "active", props.now, props.now); + } + + public static restore(props: { + readonly createdAt: Date; + readonly email: EmailAddress; + readonly id: string; + readonly status: AccountStatus; + readonly updatedAt: Date; + readonly userId: string; + }): Account { + return new Account( + props.id, + props.userId, + props.email, + props.status, + props.createdAt, + props.updatedAt, + ); + } + + private constructor( + public readonly id: string, + public readonly userId: string, + public readonly email: EmailAddress, + public readonly status: AccountStatus, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} +} diff --git a/src/modules/identity/domain/entities/credential.entity.ts b/src/modules/identity/domain/entities/credential.entity.ts new file mode 100644 index 0000000..67a4873 --- /dev/null +++ b/src/modules/identity/domain/entities/credential.entity.ts @@ -0,0 +1,25 @@ +export class Credential { + public static create(props: { + readonly accountId: string; + readonly now: Date; + readonly passwordHash: string; + }): Credential { + return new Credential(props.accountId, props.passwordHash, props.now, props.now); + } + + public static restore(props: { + readonly accountId: string; + readonly createdAt: Date; + readonly passwordHash: string; + readonly updatedAt: Date; + }): Credential { + return new Credential(props.accountId, props.passwordHash, props.createdAt, props.updatedAt); + } + + private constructor( + public readonly accountId: string, + public readonly passwordHash: string, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} +} diff --git a/src/modules/identity/domain/entities/session.entity.ts b/src/modules/identity/domain/entities/session.entity.ts new file mode 100644 index 0000000..efe72c8 --- /dev/null +++ b/src/modules/identity/domain/entities/session.entity.ts @@ -0,0 +1,84 @@ +import { SessionAlreadyInvalidatedError } from "../identity.errors"; + +export type SessionStatus = "active" | "revoked"; + +export class Session { + public static start(props: { + readonly accountId: string; + readonly expiresAt: Date; + readonly id: string; + readonly jti: string; + readonly now: Date; + readonly userId: string; + }): Session { + return new Session( + props.id, + props.accountId, + props.userId, + props.jti, + "active", + props.now, + props.now, + props.expiresAt, + null, + ); + } + + public static restore(props: { + readonly accountId: string; + readonly createdAt: Date; + readonly expiresAt: Date; + readonly id: string; + readonly jti: string; + readonly revokedAt: Date | null; + readonly status: SessionStatus; + readonly updatedAt: Date; + readonly userId: string; + }): Session { + return new Session( + props.id, + props.accountId, + props.userId, + props.jti, + props.status, + props.createdAt, + props.updatedAt, + props.expiresAt, + props.revokedAt, + ); + } + + private constructor( + public readonly id: string, + public readonly accountId: string, + public readonly userId: string, + public readonly jti: string, + public readonly status: SessionStatus, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly expiresAt: Date, + public readonly revokedAt: Date | null, + ) {} + + public revoke(now: Date): Session { + if (this.status === "revoked") { + throw new SessionAlreadyInvalidatedError(); + } + + return new Session( + this.id, + this.accountId, + this.userId, + this.jti, + "revoked", + this.createdAt, + now, + this.expiresAt, + now, + ); + } + + public isExpired(at: Date): boolean { + return this.expiresAt.getTime() <= at.getTime(); + } +} diff --git a/src/modules/identity/domain/identity.errors.ts b/src/modules/identity/domain/identity.errors.ts new file mode 100644 index 0000000..c3adc22 --- /dev/null +++ b/src/modules/identity/domain/identity.errors.ts @@ -0,0 +1,49 @@ +import { + AuthenticationError, + ConflictError, + ValidationError, +} from "../../../shared/domain/nexus.errors"; + +export class InvalidEmailError extends ValidationError { + public constructor() { + super("Email address is invalid", "invalid_email", "Email address is invalid"); + } +} + +export class WeakPasswordError extends ValidationError { + public constructor() { + super( + "Password must have at least 8 characters, one letter and one number", + "weak_password", + "Password must have at least 8 characters, one letter and one number", + ); + } +} + +export class DuplicateAccountEmailError extends ConflictError { + public constructor() { + super( + "Account email is already in use", + "duplicate_account_email", + "Account email already exists", + ); + } +} + +export class InvalidCredentialsError extends AuthenticationError { + public constructor() { + super("Authentication failed", "invalid_credentials", "Invalid credentials"); + } +} + +export class InvalidAccessTokenError extends AuthenticationError { + public constructor() { + super("Access token is invalid", "invalid_access_token", "Unauthorized"); + } +} + +export class SessionAlreadyInvalidatedError extends AuthenticationError { + public constructor() { + super("Session has already been invalidated", "session_invalidated", "Unauthorized"); + } +} diff --git a/src/modules/identity/domain/repositories/account.repository.ts b/src/modules/identity/domain/repositories/account.repository.ts new file mode 100644 index 0000000..7527da8 --- /dev/null +++ b/src/modules/identity/domain/repositories/account.repository.ts @@ -0,0 +1,10 @@ +import type { Account } from "../entities/account.entity"; +import type { EmailAddress } from "../value-objects/email-address.value-object"; + +export const ACCOUNT_REPOSITORY = Symbol("ACCOUNT_REPOSITORY"); + +export interface AccountRepository { + findByEmail(email: EmailAddress): Promise; + findById(accountId: string): Promise; + save(account: Account): Promise; +} diff --git a/src/modules/identity/domain/repositories/credential.repository.ts b/src/modules/identity/domain/repositories/credential.repository.ts new file mode 100644 index 0000000..e8a44dd --- /dev/null +++ b/src/modules/identity/domain/repositories/credential.repository.ts @@ -0,0 +1,8 @@ +import type { Credential } from "../entities/credential.entity"; + +export const CREDENTIAL_REPOSITORY = Symbol("CREDENTIAL_REPOSITORY"); + +export interface CredentialRepository { + findByAccountId(accountId: string): Promise; + save(credential: Credential): Promise; +} diff --git a/src/modules/identity/domain/repositories/session.repository.ts b/src/modules/identity/domain/repositories/session.repository.ts new file mode 100644 index 0000000..e0043e9 --- /dev/null +++ b/src/modules/identity/domain/repositories/session.repository.ts @@ -0,0 +1,9 @@ +import type { Session } from "../entities/session.entity"; + +export const SESSION_REPOSITORY = Symbol("SESSION_REPOSITORY"); + +export interface SessionRepository { + findById(sessionId: string): Promise; + save(session: Session): Promise; + update(session: Session): Promise; +} diff --git a/src/modules/identity/domain/services/credential-policy.service.ts b/src/modules/identity/domain/services/credential-policy.service.ts new file mode 100644 index 0000000..366991a --- /dev/null +++ b/src/modules/identity/domain/services/credential-policy.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@nestjs/common"; + +import { WeakPasswordError } from "../identity.errors"; + +@Injectable() +export class CredentialPolicyService { + public ensurePasswordIsAllowed(password: string): void { + if (password.length < 8 || password.length > 128) { + throw new WeakPasswordError(); + } + + if (!/[a-z]/i.test(password) || !/[0-9]/.test(password)) { + throw new WeakPasswordError(); + } + } +} diff --git a/src/modules/identity/domain/value-objects/email-address.value-object.ts b/src/modules/identity/domain/value-objects/email-address.value-object.ts new file mode 100644 index 0000000..ff08639 --- /dev/null +++ b/src/modules/identity/domain/value-objects/email-address.value-object.ts @@ -0,0 +1,21 @@ +import { InvalidEmailError } from "../identity.errors"; + +const EMAIL_PATTERN = + /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; + +export class EmailAddress { + public static create(value: string): EmailAddress { + const normalized = value.trim().toLowerCase(); + + if (!EMAIL_PATTERN.test(normalized)) { + throw new InvalidEmailError(); + } + + return new EmailAddress(value.trim(), normalized); + } + + private constructor( + public readonly value: string, + public readonly normalized: string, + ) {} +} diff --git a/src/modules/identity/identity.module.ts b/src/modules/identity/identity.module.ts index a8aae5c..56c93ed 100644 --- a/src/modules/identity/identity.module.ts +++ b/src/modules/identity/identity.module.ts @@ -1,4 +1,57 @@ import { Module } from "@nestjs/common"; -@Module({}) +import { UsersModule } from "../users/users.module"; +import { CreateUserAccountUseCase } from "./application/use-cases/create-user-account.use-case"; +import { InvalidateSessionUseCase } from "./application/use-cases/invalidate-session.use-case"; +import { LoginWithPasswordUseCase } from "./application/use-cases/login-with-password.use-case"; +import { ResolveAuthenticatedPrincipalUseCase } from "./application/use-cases/resolve-authenticated-principal.use-case"; +import { ACCOUNT_REPOSITORY } from "./domain/repositories/account.repository"; +import { CREDENTIAL_REPOSITORY } from "./domain/repositories/credential.repository"; +import { SESSION_REPOSITORY } from "./domain/repositories/session.repository"; +import { CredentialPolicyService } from "./domain/services/credential-policy.service"; +import { IdentityController } from "./infrastructure/http/identity.controller"; +import { PgAccountRepository } from "./infrastructure/persistence/pg-account.repository"; +import { PgCredentialRepository } from "./infrastructure/persistence/pg-credential.repository"; +import { PgSessionRepository } from "./infrastructure/persistence/pg-session.repository"; +import { Argon2PasswordHasher } from "./infrastructure/security/argon2-password-hasher"; +import { JwtAccessTokenService } from "./infrastructure/security/jwt-access-token.service"; +import { ACCESS_TOKEN_SERVICE } from "./application/ports/access-token.service"; +import { PASSWORD_HASHER } from "./application/ports/password-hasher.port"; + +@Module({ + controllers: [IdentityController], + imports: [UsersModule], + providers: [ + CredentialPolicyService, + CreateUserAccountUseCase, + LoginWithPasswordUseCase, + ResolveAuthenticatedPrincipalUseCase, + InvalidateSessionUseCase, + PgAccountRepository, + PgCredentialRepository, + PgSessionRepository, + Argon2PasswordHasher, + JwtAccessTokenService, + { + provide: ACCOUNT_REPOSITORY, + useExisting: PgAccountRepository, + }, + { + provide: CREDENTIAL_REPOSITORY, + useExisting: PgCredentialRepository, + }, + { + provide: SESSION_REPOSITORY, + useExisting: PgSessionRepository, + }, + { + provide: PASSWORD_HASHER, + useExisting: Argon2PasswordHasher, + }, + { + provide: ACCESS_TOKEN_SERVICE, + useExisting: JwtAccessTokenService, + }, + ], +}) export class IdentityModule {} diff --git a/src/modules/identity/infrastructure/http/create-account.request.ts b/src/modules/identity/infrastructure/http/create-account.request.ts new file mode 100644 index 0000000..711e765 --- /dev/null +++ b/src/modules/identity/infrastructure/http/create-account.request.ts @@ -0,0 +1,16 @@ +import { IsEmail, IsString, MaxLength, MinLength } from "class-validator"; + +export class CreateAccountRequestDto { + @IsString() + @MinLength(3) + @MaxLength(120) + public readonly fullName!: string; + + @IsEmail() + public readonly email!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + public readonly password!: string; +} diff --git a/src/modules/identity/infrastructure/http/identity.controller.ts b/src/modules/identity/infrastructure/http/identity.controller.ts new file mode 100644 index 0000000..3e2075a --- /dev/null +++ b/src/modules/identity/infrastructure/http/identity.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Headers, HttpCode, HttpStatus, Post } from "@nestjs/common"; + +import { InvalidAccessTokenError } from "../../domain/identity.errors"; +import { CreateUserAccountUseCase } from "../../application/use-cases/create-user-account.use-case"; +import { InvalidateSessionUseCase } from "../../application/use-cases/invalidate-session.use-case"; +import { LoginWithPasswordUseCase } from "../../application/use-cases/login-with-password.use-case"; +import { CreateAccountRequestDto } from "./create-account.request"; +import { LoginRequestDto } from "./login.request"; + +@Controller("identity") +export class IdentityController { + public constructor( + private readonly createUserAccountUseCase: CreateUserAccountUseCase, + private readonly loginWithPasswordUseCase: LoginWithPasswordUseCase, + private readonly invalidateSessionUseCase: InvalidateSessionUseCase, + ) {} + + @Post("accounts") + public createAccount(@Body() body: CreateAccountRequestDto) { + return this.createUserAccountUseCase.execute(body); + } + + @Post("login") + @HttpCode(HttpStatus.OK) + public login(@Body() body: LoginRequestDto) { + return this.loginWithPasswordUseCase.execute(body); + } + + @Post("logout") + @HttpCode(HttpStatus.NO_CONTENT) + public async logout(@Headers("authorization") authorization?: string): Promise { + const accessToken = this.readAccessToken(authorization); + + await this.invalidateSessionUseCase.execute(accessToken); + } + + private readAccessToken(authorization?: string): string { + if (authorization === undefined) { + throw new InvalidAccessTokenError(); + } + + const [scheme, token] = authorization.split(" "); + + if (scheme !== "Bearer" || token === undefined || token.length === 0) { + throw new InvalidAccessTokenError(); + } + + return token; + } +} diff --git a/src/modules/identity/infrastructure/http/login.request.ts b/src/modules/identity/infrastructure/http/login.request.ts new file mode 100644 index 0000000..2ba1f16 --- /dev/null +++ b/src/modules/identity/infrastructure/http/login.request.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsString, MaxLength, MinLength } from "class-validator"; + +export class LoginRequestDto { + @IsEmail() + public readonly email!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + public readonly password!: string; +} diff --git a/src/modules/identity/infrastructure/persistence/pg-account.repository.ts b/src/modules/identity/infrastructure/persistence/pg-account.repository.ts new file mode 100644 index 0000000..aaa610e --- /dev/null +++ b/src/modules/identity/infrastructure/persistence/pg-account.repository.ts @@ -0,0 +1,106 @@ +import { Injectable } from "@nestjs/common"; + +import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { DuplicateAccountEmailError } from "../../domain/identity.errors"; +import { Account } from "../../domain/entities/account.entity"; +import type { AccountRepository } from "../../domain/repositories/account.repository"; +import { EmailAddress } from "../../domain/value-objects/email-address.value-object"; + +interface AccountRow { + readonly created_at: Date; + readonly email: string; + readonly id: string; + readonly normalized_email: string; + readonly status: "active" | "inactive"; + readonly updated_at: Date; + readonly user_id: string; +} + +@Injectable() +export class PgAccountRepository implements AccountRepository { + public constructor(private readonly databaseExecutor: DatabaseExecutor) {} + + public async findByEmail(email: EmailAddress): Promise { + const result = await this.databaseExecutor.query( + ` + SELECT id, user_id, email, normalized_email, status, created_at, updated_at + FROM accounts + WHERE normalized_email = $1 + `, + [email.normalized], + ); + + const row = result.rows[0]; + + if (row === undefined) { + return null; + } + + return this.mapRow(row); + } + + public async findById(accountId: string): Promise { + const result = await this.databaseExecutor.query( + ` + SELECT id, user_id, email, normalized_email, status, created_at, updated_at + FROM accounts + WHERE id = $1 + `, + [accountId], + ); + + const row = result.rows[0]; + + if (row === undefined) { + return null; + } + + return this.mapRow(row); + } + + public async save(account: Account): Promise { + try { + await this.databaseExecutor.query( + ` + INSERT INTO accounts (id, user_id, email, normalized_email, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, + [ + account.id, + account.userId, + account.email.value, + account.email.normalized, + account.status, + account.createdAt, + account.updatedAt, + ], + ); + } catch (error) { + if (this.isUniqueViolation(error)) { + throw new DuplicateAccountEmailError(); + } + + throw error; + } + } + + private isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "23505" + ); + } + + private mapRow(row: AccountRow): Account { + return Account.restore({ + createdAt: new Date(row.created_at), + email: EmailAddress.create(row.normalized_email), + id: row.id, + status: row.status, + updatedAt: new Date(row.updated_at), + userId: row.user_id, + }); + } +} diff --git a/src/modules/identity/infrastructure/persistence/pg-credential.repository.ts b/src/modules/identity/infrastructure/persistence/pg-credential.repository.ts new file mode 100644 index 0000000..325471e --- /dev/null +++ b/src/modules/identity/infrastructure/persistence/pg-credential.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from "@nestjs/common"; + +import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { Credential } from "../../domain/entities/credential.entity"; +import type { CredentialRepository } from "../../domain/repositories/credential.repository"; + +interface CredentialRow { + readonly account_id: string; + readonly created_at: Date; + readonly password_hash: string; + readonly updated_at: Date; +} + +@Injectable() +export class PgCredentialRepository implements CredentialRepository { + public constructor(private readonly databaseExecutor: DatabaseExecutor) {} + + public async findByAccountId(accountId: string): Promise { + const result = await this.databaseExecutor.query( + ` + SELECT account_id, password_hash, created_at, updated_at + FROM credentials + WHERE account_id = $1 + `, + [accountId], + ); + + const row = result.rows[0]; + + if (row === undefined) { + return null; + } + + return Credential.restore({ + accountId: row.account_id, + createdAt: new Date(row.created_at), + passwordHash: row.password_hash, + updatedAt: new Date(row.updated_at), + }); + } + + public async save(credential: Credential): Promise { + await this.databaseExecutor.query( + ` + INSERT INTO credentials (account_id, password_hash, created_at, updated_at) + VALUES ($1, $2, $3, $4) + `, + [ + credential.accountId, + credential.passwordHash, + credential.createdAt, + credential.updatedAt, + ], + ); + } +} diff --git a/src/modules/identity/infrastructure/persistence/pg-session.repository.ts b/src/modules/identity/infrastructure/persistence/pg-session.repository.ts new file mode 100644 index 0000000..a3861eb --- /dev/null +++ b/src/modules/identity/infrastructure/persistence/pg-session.repository.ts @@ -0,0 +1,88 @@ +import { Injectable } from "@nestjs/common"; + +import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { Session } from "../../domain/entities/session.entity"; +import type { SessionRepository } from "../../domain/repositories/session.repository"; + +interface SessionRow { + readonly account_id: string; + readonly created_at: Date; + readonly expires_at: Date; + readonly id: string; + readonly jti: string; + readonly revoked_at: Date | null; + readonly status: "active" | "revoked"; + readonly updated_at: Date; + readonly user_id: string; +} + +@Injectable() +export class PgSessionRepository implements SessionRepository { + public constructor(private readonly databaseExecutor: DatabaseExecutor) {} + + public async findById(sessionId: string): Promise { + const result = await this.databaseExecutor.query( + ` + SELECT id, account_id, user_id, jti, status, created_at, updated_at, expires_at, revoked_at + FROM sessions + WHERE id = $1 + `, + [sessionId], + ); + + const row = result.rows[0]; + + if (row === undefined) { + return null; + } + + return this.mapRow(row); + } + + public async save(session: Session): Promise { + await this.databaseExecutor.query( + ` + INSERT INTO sessions (id, account_id, user_id, jti, status, created_at, updated_at, expires_at, revoked_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + [ + session.id, + session.accountId, + session.userId, + session.jti, + session.status, + session.createdAt, + session.updatedAt, + session.expiresAt, + session.revokedAt, + ], + ); + } + + public async update(session: Session): Promise { + await this.databaseExecutor.query( + ` + UPDATE sessions + SET status = $2, + updated_at = $3, + revoked_at = $4 + WHERE id = $1 + `, + [session.id, session.status, session.updatedAt, session.revokedAt], + ); + } + + private mapRow(row: SessionRow): Session { + return Session.restore({ + accountId: row.account_id, + createdAt: new Date(row.created_at), + expiresAt: new Date(row.expires_at), + id: row.id, + jti: row.jti, + revokedAt: row.revoked_at === null ? null : new Date(row.revoked_at), + status: row.status, + updatedAt: new Date(row.updated_at), + userId: row.user_id, + }); + } +} diff --git a/src/modules/identity/infrastructure/security/argon2-password-hasher.ts b/src/modules/identity/infrastructure/security/argon2-password-hasher.ts new file mode 100644 index 0000000..1b631a3 --- /dev/null +++ b/src/modules/identity/infrastructure/security/argon2-password-hasher.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; +import * as argon2 from "argon2"; + +import type { PasswordHasher } from "../../application/ports/password-hasher.port"; + +@Injectable() +export class Argon2PasswordHasher implements PasswordHasher { + public hash(password: string): Promise { + return argon2.hash(password, { + memoryCost: 19_456, + parallelism: 1, + timeCost: 2, + type: argon2.argon2id, + }); + } + + public verify(hash: string, password: string): Promise { + return argon2.verify(hash, password); + } +} diff --git a/src/modules/identity/infrastructure/security/jwt-access-token.service.ts b/src/modules/identity/infrastructure/security/jwt-access-token.service.ts new file mode 100644 index 0000000..cc9926d --- /dev/null +++ b/src/modules/identity/infrastructure/security/jwt-access-token.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from "@nestjs/common"; +import jwt from "jsonwebtoken"; + +import { ApplicationConfigService } from "../../../../bootstrap/config/application-config.service"; +import { InvalidAccessTokenError } from "../../domain/identity.errors"; +import type { + AccessTokenPayload, + AccessTokenService, +} from "../../application/ports/access-token.service"; + +@Injectable() +export class JwtAccessTokenService implements AccessTokenService { + public constructor(private readonly configuration: ApplicationConfigService) {} + + public async issue(payload: AccessTokenPayload, expiresAt: Date): Promise { + return new Promise((resolve, reject) => { + jwt.sign( + payload, + this.configuration.auth.jwtSecret, + { + algorithm: "HS256", + expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)), + }, + (error, token) => { + if (error !== null || token === undefined) { + reject(error ?? new Error("Failed to sign access token")); + return; + } + + resolve(token); + }, + ); + }); + } + + public async verify(token: string): Promise { + try { + const payload = await new Promise((resolve, reject) => { + jwt.verify(token, this.configuration.auth.jwtSecret, { algorithms: ["HS256"] }, (error, decoded) => { + if (error !== null || decoded === undefined) { + reject(error ?? new Error("Failed to verify access token")); + return; + } + + resolve(decoded); + }); + }); + + if (typeof payload === "string") { + throw new InvalidAccessTokenError(); + } + + if ( + typeof payload.sub !== "string" || + typeof payload.sid !== "string" || + typeof payload.aid !== "string" || + typeof payload.jti !== "string" + ) { + throw new InvalidAccessTokenError(); + } + + return { + aid: payload.aid, + jti: payload.jti, + sid: payload.sid, + sub: payload.sub, + }; + } catch { + throw new InvalidAccessTokenError(); + } + } +} diff --git a/src/modules/users/application/contracts/users-identity.contract.ts b/src/modules/users/application/contracts/users-identity.contract.ts new file mode 100644 index 0000000..917540a --- /dev/null +++ b/src/modules/users/application/contracts/users-identity.contract.ts @@ -0,0 +1,19 @@ +import type { UserStatus } from "../../domain/entities/user.entity"; + +export const USERS_IDENTITY_CONTRACT = Symbol("USERS_IDENTITY_CONTRACT"); + +export interface CreateUserInput { + readonly fullName: string; + readonly userId: string; +} + +export interface UserSnapshot { + readonly fullName: string; + readonly status: UserStatus; + readonly userId: string; +} + +export interface UsersIdentityContract { + createUser(input: CreateUserInput): Promise; + getUserById(userId: string): Promise; +} diff --git a/src/modules/users/application/use-cases/create-user.use-case.ts b/src/modules/users/application/use-cases/create-user.use-case.ts new file mode 100644 index 0000000..d9cafb7 --- /dev/null +++ b/src/modules/users/application/use-cases/create-user.use-case.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { User } from "../../domain/entities/user.entity"; +import { USER_REPOSITORY, type UserRepository } from "../../domain/repositories/user.repository"; +import type { CreateUserInput, UserSnapshot } from "../contracts/users-identity.contract"; + +@Injectable() +export class CreateUserUseCase { + public constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + ) {} + + public async execute(input: CreateUserInput): Promise { + const user = User.create({ + fullName: input.fullName, + id: input.userId, + now: new Date(), + }); + + await this.userRepository.save(user); + + return { + fullName: user.fullName, + status: user.status, + userId: user.id, + }; + } +} diff --git a/src/modules/users/application/use-cases/get-user-by-id.use-case.ts b/src/modules/users/application/use-cases/get-user-by-id.use-case.ts new file mode 100644 index 0000000..3ff72e8 --- /dev/null +++ b/src/modules/users/application/use-cases/get-user-by-id.use-case.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { USER_REPOSITORY, type UserRepository } from "../../domain/repositories/user.repository"; +import type { UserSnapshot } from "../contracts/users-identity.contract"; + +@Injectable() +export class GetUserByIdUseCase { + public constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + ) {} + + public async execute(userId: string): Promise { + const user = await this.userRepository.findById(userId); + + if (user === null) { + return null; + } + + return { + fullName: user.fullName, + status: user.status, + userId: user.id, + }; + } +} diff --git a/src/modules/users/domain/entities/user.entity.ts b/src/modules/users/domain/entities/user.entity.ts new file mode 100644 index 0000000..c949706 --- /dev/null +++ b/src/modules/users/domain/entities/user.entity.ts @@ -0,0 +1,45 @@ +import { ValidationError } from "../../../../shared/domain/nexus.errors"; + +export type UserStatus = "active" | "inactive"; + +export class InvalidUserFullNameError extends ValidationError { + public constructor() { + super("User full name must contain between 3 and 120 characters", "invalid_user_full_name", "Full name must contain between 3 and 120 characters"); + } +} + +export interface CreateUserProps { + readonly id: string; + readonly fullName: string; + readonly now: Date; +} + +export class User { + public static create(props: CreateUserProps): User { + const normalizedFullName = props.fullName.trim(); + + if (normalizedFullName.length < 3 || normalizedFullName.length > 120) { + throw new InvalidUserFullNameError(); + } + + return new User(props.id, normalizedFullName, "active", props.now, props.now); + } + + public static restore(props: { + readonly createdAt: Date; + readonly fullName: string; + readonly id: string; + readonly status: UserStatus; + readonly updatedAt: Date; + }): User { + return new User(props.id, props.fullName, props.status, props.createdAt, props.updatedAt); + } + + private constructor( + public readonly id: string, + public readonly fullName: string, + public readonly status: UserStatus, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} +} diff --git a/src/modules/users/domain/repositories/user.repository.ts b/src/modules/users/domain/repositories/user.repository.ts new file mode 100644 index 0000000..5e10561 --- /dev/null +++ b/src/modules/users/domain/repositories/user.repository.ts @@ -0,0 +1,8 @@ +import type { User } from "../entities/user.entity"; + +export const USER_REPOSITORY = Symbol("USER_REPOSITORY"); + +export interface UserRepository { + findById(userId: string): Promise; + save(user: User): Promise; +} diff --git a/src/modules/users/infrastructure/persistence/pg-user.repository.ts b/src/modules/users/infrastructure/persistence/pg-user.repository.ts new file mode 100644 index 0000000..e6b63fe --- /dev/null +++ b/src/modules/users/infrastructure/persistence/pg-user.repository.ts @@ -0,0 +1,53 @@ +import { Injectable } from "@nestjs/common"; + +import { DatabaseExecutor } from "../../../../bootstrap/persistence/database.executor"; +import { User } from "../../domain/entities/user.entity"; +import type { UserRepository } from "../../domain/repositories/user.repository"; + +interface UserRow { + readonly created_at: Date; + readonly full_name: string; + readonly id: string; + readonly status: "active" | "inactive"; + readonly updated_at: Date; +} + +@Injectable() +export class PgUserRepository implements UserRepository { + public constructor(private readonly databaseExecutor: DatabaseExecutor) {} + + public async findById(userId: string): Promise { + const result = await this.databaseExecutor.query( + ` + SELECT id, full_name, status, created_at, updated_at + FROM users + WHERE id = $1 + `, + [userId], + ); + + const row = result.rows[0]; + + if (row === undefined) { + return null; + } + + return User.restore({ + createdAt: new Date(row.created_at), + fullName: row.full_name, + id: row.id, + status: row.status, + updatedAt: new Date(row.updated_at), + }); + } + + public async save(user: User): Promise { + await this.databaseExecutor.query( + ` + INSERT INTO users (id, full_name, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + `, + [user.id, user.fullName, user.status, user.createdAt, user.updatedAt], + ); + } +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index c80abf2..dfe2a63 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,4 +1,48 @@ import { Module } from "@nestjs/common"; -@Module({}) +import { + USERS_IDENTITY_CONTRACT, + type UsersIdentityContract, +} from "./application/contracts/users-identity.contract"; +import { CreateUserUseCase } from "./application/use-cases/create-user.use-case"; +import { GetUserByIdUseCase } from "./application/use-cases/get-user-by-id.use-case"; +import { USER_REPOSITORY } from "./domain/repositories/user.repository"; +import { PgUserRepository } from "./infrastructure/persistence/pg-user.repository"; + +class UsersIdentityContractAdapter implements UsersIdentityContract { + public constructor( + private readonly createUserUseCase: CreateUserUseCase, + private readonly getUserByIdUseCase: GetUserByIdUseCase, + ) {} + + public createUser(input: Parameters[0]) { + return this.createUserUseCase.execute(input); + } + + public getUserById(userId: string) { + return this.getUserByIdUseCase.execute(userId); + } +} + +@Module({ + providers: [ + CreateUserUseCase, + GetUserByIdUseCase, + PgUserRepository, + { + provide: USER_REPOSITORY, + useExisting: PgUserRepository, + }, + { + provide: USERS_IDENTITY_CONTRACT, + inject: [CreateUserUseCase, GetUserByIdUseCase], + useFactory: ( + createUserUseCase: CreateUserUseCase, + getUserByIdUseCase: GetUserByIdUseCase, + ): UsersIdentityContract => + new UsersIdentityContractAdapter(createUserUseCase, getUserByIdUseCase), + }, + ], + exports: [USERS_IDENTITY_CONTRACT], +}) export class UsersModule {} diff --git a/src/shared/domain/nexus.errors.ts b/src/shared/domain/nexus.errors.ts new file mode 100644 index 0000000..7d92de8 --- /dev/null +++ b/src/shared/domain/nexus.errors.ts @@ -0,0 +1,20 @@ +export abstract class NexusError extends Error { + public constructor( + message: string, + public readonly code: string, + public readonly publicMessage: string, + ) { + super(message); + this.name = new.target.name; + } +} + +export abstract class ValidationError extends NexusError {} + +export abstract class ConflictError extends NexusError {} + +export abstract class AuthenticationError extends NexusError {} + +export abstract class AuthorizationError extends NexusError {} + +export abstract class NotFoundError extends NexusError {} diff --git a/test/functional/health.e2e-spec.ts b/test/functional/health.e2e-spec.ts index 94db30b..f8651e4 100644 --- a/test/functional/health.e2e-spec.ts +++ b/test/functional/health.e2e-spec.ts @@ -15,6 +15,8 @@ describeIfDocker("Health endpoint", () => { beforeAll(async () => { container = await new PostgreSqlContainer("postgres:16-alpine").start(); process.env.APP_PORT = "3000"; + process.env.AUTH_JWT_EXPIRES_IN_MINUTES = "30"; + process.env.AUTH_JWT_SECRET = "functional-secret"; process.env.DB_HOST = container.getHost(); process.env.DB_NAME = container.getDatabase(); process.env.DB_PASSWORD = container.getPassword(); diff --git a/test/functional/identity/identity.e2e-spec.ts b/test/functional/identity/identity.e2e-spec.ts new file mode 100644 index 0000000..2dec53c --- /dev/null +++ b/test/functional/identity/identity.e2e-spec.ts @@ -0,0 +1,97 @@ +import type { INestApplication } from "@nestjs/common"; +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import request from "supertest"; + +import { createApplication, disposeApplication } from "../../../src/bootstrap/application.factory"; +import { initializeTelemetry } from "../../../src/bootstrap/telemetry/telemetry.sdk"; +import { isDockerAvailable } from "../../support/docker-availability"; + +const describeIfDocker = isDockerAvailable() ? describe : describe.skip; + +describeIfDocker("Identity endpoints", () => { + let application: INestApplication; + let container: StartedPostgreSqlContainer; + + beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:16-alpine").start(); + process.env.APP_PORT = "3000"; + process.env.AUTH_JWT_EXPIRES_IN_MINUTES = "30"; + process.env.AUTH_JWT_SECRET = "functional-secret"; + process.env.DB_HOST = container.getHost(); + process.env.DB_NAME = container.getDatabase(); + process.env.DB_PASSWORD = container.getPassword(); + process.env.DB_PORT = container.getPort().toString(); + process.env.DB_USER = container.getUsername(); + process.env.NODE_ENV = "test"; + + await initializeTelemetry(); + application = await createApplication(); + await application.init(); + }); + + afterAll(async () => { + await disposeApplication(application); + await container.stop(); + }); + + it("creates an account, logs in and invalidates the session", async () => { + const httpServer = application.getHttpServer() as Parameters[0]; + + const createAccountResponse = await request(httpServer) + .post("/identity/accounts") + .send({ + email: "jane@example.com", + fullName: "Jane Doe", + password: "Password123", + }) + .expect(201); + + expect(createAccountResponse.body).toMatchObject({ + email: "jane@example.com", + status: "active", + }); + + const loginResponse = await request(httpServer) + .post("/identity/login") + .send({ + email: "jane@example.com", + password: "Password123", + }) + .expect(200); + + expect(loginResponse.body).toMatchObject({ + tokenType: "Bearer", + principal: { + email: "jane@example.com", + }, + }); + + await request(httpServer) + .post("/identity/logout") + .set("Authorization", `Bearer ${loginResponse.body.accessToken}`) + .expect(204); + + await request(httpServer) + .post("/identity/logout") + .set("Authorization", `Bearer ${loginResponse.body.accessToken}`) + .expect(401); + }); + + it("fails with a generic invalid-credentials response", async () => { + const httpServer = application.getHttpServer() as Parameters[0]; + + const response = await request(httpServer) + .post("/identity/login") + .send({ + email: "missing@example.com", + password: "Password123", + }) + .expect(401); + + expect(response.body).toEqual({ + error: "Unauthorized", + message: "Invalid credentials", + statusCode: 401, + }); + }); +}); diff --git a/test/integration/bootstrap/persistence/database.module.integration.spec.ts b/test/integration/bootstrap/persistence/database.module.integration.spec.ts index 0df7219..509297b 100644 --- a/test/integration/bootstrap/persistence/database.module.integration.spec.ts +++ b/test/integration/bootstrap/persistence/database.module.integration.spec.ts @@ -55,6 +55,8 @@ async function createTestingApplication() { function configureDatabaseEnvironment(container: StartedPostgreSqlContainer): void { process.env.APP_PORT = "3000"; + process.env.AUTH_JWT_EXPIRES_IN_MINUTES = "30"; + process.env.AUTH_JWT_SECRET = "integration-secret"; process.env.DB_HOST = container.getHost(); process.env.DB_NAME = container.getDatabase(); process.env.DB_PASSWORD = container.getPassword(); diff --git a/test/integration/modules/identity/identity.integration.spec.ts b/test/integration/modules/identity/identity.integration.spec.ts new file mode 100644 index 0000000..c9e4b3f --- /dev/null +++ b/test/integration/modules/identity/identity.integration.spec.ts @@ -0,0 +1,108 @@ +import { Test } from "@nestjs/testing"; +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import type { Pool } from "pg"; + +import { AppConfigModule } from "../../../../src/bootstrap/config/app-config.module"; +import { LoggingModule } from "../../../../src/bootstrap/logging/logging.module"; +import { DATABASE_POOL } from "../../../../src/bootstrap/persistence/database.constants"; +import { DatabaseModule } from "../../../../src/bootstrap/persistence/database.module"; +import { CreateUserAccountUseCase } from "../../../../src/modules/identity/application/use-cases/create-user-account.use-case"; +import { InvalidateSessionUseCase } from "../../../../src/modules/identity/application/use-cases/invalidate-session.use-case"; +import { LoginWithPasswordUseCase } from "../../../../src/modules/identity/application/use-cases/login-with-password.use-case"; +import { IdentityModule } from "../../../../src/modules/identity/identity.module"; +import { UsersModule } from "../../../../src/modules/users/users.module"; +import { isDockerAvailable } from "../../../support/docker-availability"; + +const describeIfDocker = isDockerAvailable() ? describe : describe.skip; + +describeIfDocker("Identity integration", () => { + let container: StartedPostgreSqlContainer; + + beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:16-alpine").start(); + }); + + afterAll(async () => { + await container.stop(); + }); + + it("persists users, accounts and credentials when creating an account", async () => { + configureDatabaseEnvironment(container); + const application = await createTestingApplication(); + const createUserAccount = application.get(CreateUserAccountUseCase); + const pool = application.get(DATABASE_POOL); + + const result = await createUserAccount.execute({ + email: "jane@example.com", + fullName: "Jane Doe", + password: "Password123", + }); + + const users = await pool.query("SELECT full_name, status FROM users WHERE id = $1", [result.userId]); + const accounts = await pool.query( + "SELECT normalized_email, status FROM accounts WHERE id = $1", + [result.accountId], + ); + const credentials = await pool.query( + "SELECT password_hash FROM credentials WHERE account_id = $1", + [result.accountId], + ); + + expect(users.rows[0]).toEqual({ full_name: "Jane Doe", status: "active" }); + expect(accounts.rows[0]).toEqual({ normalized_email: "jane@example.com", status: "active" }); + expect(credentials.rows[0].password_hash).not.toBe("Password123"); + await application.close(); + }); + + it("creates and invalidates a persisted session during login/logout", async () => { + configureDatabaseEnvironment(container); + const application = await createTestingApplication(); + const createUserAccount = application.get(CreateUserAccountUseCase); + const loginWithPassword = application.get(LoginWithPasswordUseCase); + const invalidateSession = application.get(InvalidateSessionUseCase); + const pool = application.get(DATABASE_POOL); + + await createUserAccount.execute({ + email: "john@example.com", + fullName: "John Doe", + password: "Password123", + }); + + const login = await loginWithPassword.execute({ + email: "john@example.com", + password: "Password123", + }); + + await invalidateSession.execute(login.accessToken); + + const sessions = await pool.query("SELECT status, revoked_at FROM sessions WHERE id = $1", [ + login.sessionId, + ]); + + expect(sessions.rows[0].status).toBe("revoked"); + expect(sessions.rows[0].revoked_at).not.toBeNull(); + await application.close(); + }); +}); + +async function createTestingApplication() { + const moduleReference = await Test.createTestingModule({ + imports: [AppConfigModule, LoggingModule, DatabaseModule, UsersModule, IdentityModule], + }).compile(); + + await moduleReference.init(); + + return moduleReference; +} + +function configureDatabaseEnvironment(container: StartedPostgreSqlContainer): void { + process.env.APP_PORT = "3000"; + process.env.AUTH_JWT_EXPIRES_IN_MINUTES = "30"; + process.env.AUTH_JWT_SECRET = "integration-secret"; + process.env.DB_HOST = container.getHost(); + process.env.DB_NAME = container.getDatabase(); + process.env.DB_PASSWORD = container.getPassword(); + process.env.DB_PORT = container.getPort().toString(); + process.env.DB_USER = container.getUsername(); + process.env.NODE_ENV = "test"; +} diff --git a/test/unit/bootstrap/config/load-app-config.spec.ts b/test/unit/bootstrap/config/load-app-config.spec.ts index 703e3f7..0ccdf24 100644 --- a/test/unit/bootstrap/config/load-app-config.spec.ts +++ b/test/unit/bootstrap/config/load-app-config.spec.ts @@ -4,6 +4,7 @@ describe("loadAppConfig", () => { it("parses the required environment variables", () => { const config = loadAppConfig({ APP_PORT: "3000", + AUTH_JWT_SECRET: "test-secret", DB_HOST: "localhost", DB_NAME: "nexus", DB_PASSWORD: "postgres", @@ -17,6 +18,10 @@ describe("loadAppConfig", () => { nodeEnv: "test", port: 3000, }, + auth: { + jwtExpiresInMinutes: 480, + jwtSecret: "test-secret", + }, database: { host: "localhost", name: "nexus", @@ -31,6 +36,7 @@ describe("loadAppConfig", () => { expect(() => loadAppConfig({ APP_PORT: "3000", + AUTH_JWT_SECRET: "test-secret", DB_HOST: "localhost", DB_NAME: "nexus", DB_PASSWORD: "postgres", @@ -38,4 +44,19 @@ describe("loadAppConfig", () => { }), ).toThrow("Missing required environment variable: DB_PORT"); }); + + it("uses the default JWT expiration when it is not configured", () => { + const config = loadAppConfig({ + APP_PORT: "3000", + AUTH_JWT_SECRET: "test-secret", + DB_HOST: "localhost", + DB_NAME: "nexus", + DB_PASSWORD: "postgres", + DB_PORT: "5432", + DB_USER: "postgres", + NODE_ENV: "test", + }); + + expect(config.auth.jwtExpiresInMinutes).toBe(480); + }); }); diff --git a/test/unit/bootstrap/logging/pino-logger.config.spec.ts b/test/unit/bootstrap/logging/pino-logger.config.spec.ts index e97f6e6..753d128 100644 --- a/test/unit/bootstrap/logging/pino-logger.config.spec.ts +++ b/test/unit/bootstrap/logging/pino-logger.config.spec.ts @@ -18,6 +18,10 @@ describe("buildPinoHttpConfiguration", () => { nodeEnv: "test", port: 3000, }, + auth: { + jwtExpiresInMinutes: 30, + jwtSecret: "test-secret", + }, database: { host: "localhost", name: "nexus", diff --git a/test/unit/modules/identity/application/create-user-account.use-case.spec.ts b/test/unit/modules/identity/application/create-user-account.use-case.spec.ts new file mode 100644 index 0000000..c540dd1 --- /dev/null +++ b/test/unit/modules/identity/application/create-user-account.use-case.spec.ts @@ -0,0 +1,104 @@ +import type { PinoLogger } from "nestjs-pino"; + +import type { DatabaseExecutor } from "../../../../../src/bootstrap/persistence/database.executor"; +import { CreateUserAccountUseCase } from "../../../../../src/modules/identity/application/use-cases/create-user-account.use-case"; +import { Account } from "../../../../../src/modules/identity/domain/entities/account.entity"; +import { Credential } from "../../../../../src/modules/identity/domain/entities/credential.entity"; +import { + DuplicateAccountEmailError, +} from "../../../../../src/modules/identity/domain/identity.errors"; +import { CredentialPolicyService } from "../../../../../src/modules/identity/domain/services/credential-policy.service"; +import { EmailAddress } from "../../../../../src/modules/identity/domain/value-objects/email-address.value-object"; + +function createLoggerMock(): PinoLogger { + return { + info: jest.fn(), + setContext: jest.fn(), + warn: jest.fn(), + } as unknown as PinoLogger; +} + +describe("CreateUserAccountUseCase", () => { + it("creates a user, account and credential", async () => { + const accountRepository = { + findByEmail: jest.fn().mockResolvedValue(null), + findById: jest.fn().mockResolvedValue(null), + save: jest.fn().mockResolvedValue(undefined), + }; + const credentialRepository = { + findByAccountId: jest.fn().mockResolvedValue(null), + save: jest.fn().mockResolvedValue(undefined), + }; + const passwordHasher = { + hash: jest.fn().mockResolvedValue("argon2-hash"), + verify: jest.fn(), + }; + const usersIdentityContract = { + createUser: jest.fn().mockResolvedValue({ + fullName: "Jane Doe", + status: "active", + userId: "ignored", + }), + getUserById: jest.fn().mockResolvedValue(null), + }; + const databaseExecutor = { + withTransaction: jest.fn(async (callback: () => Promise) => callback()), + } as unknown as DatabaseExecutor; + + const useCase = new CreateUserAccountUseCase( + accountRepository, + credentialRepository, + passwordHasher, + usersIdentityContract, + new CredentialPolicyService(), + databaseExecutor, + createLoggerMock(), + ); + + const result = await useCase.execute({ + email: "jane@example.com", + fullName: "Jane Doe", + password: "Password123", + }); + + expect(usersIdentityContract.createUser).toHaveBeenCalledTimes(1); + expect(accountRepository.save).toHaveBeenCalledWith(expect.any(Account)); + expect(credentialRepository.save).toHaveBeenCalledWith(expect.any(Credential)); + expect(result.email).toBe("jane@example.com"); + expect(result.status).toBe("active"); + }); + + it("fails when the email already exists", async () => { + const accountRepository = { + findByEmail: jest.fn().mockResolvedValue( + Account.create({ + email: EmailAddress.create("jane@example.com"), + id: "account-1", + now: new Date(), + userId: "user-1", + }), + ), + findById: jest.fn().mockResolvedValue(null), + }; + + const useCase = new CreateUserAccountUseCase( + accountRepository as never, + {} as never, + {} as never, + {} as never, + new CredentialPolicyService(), + { + withTransaction: jest.fn(), + } as unknown as DatabaseExecutor, + createLoggerMock(), + ); + + await expect( + useCase.execute({ + email: "jane@example.com", + fullName: "Jane Doe", + password: "Password123", + }), + ).rejects.toThrow(DuplicateAccountEmailError); + }); +}); diff --git a/test/unit/modules/identity/application/invalidate-session.use-case.spec.ts b/test/unit/modules/identity/application/invalidate-session.use-case.spec.ts new file mode 100644 index 0000000..c68bee7 --- /dev/null +++ b/test/unit/modules/identity/application/invalidate-session.use-case.spec.ts @@ -0,0 +1,77 @@ +import type { PinoLogger } from "nestjs-pino"; + +import { InvalidateSessionUseCase } from "../../../../../src/modules/identity/application/use-cases/invalidate-session.use-case"; +import { Session } from "../../../../../src/modules/identity/domain/entities/session.entity"; +import { InvalidAccessTokenError } from "../../../../../src/modules/identity/domain/identity.errors"; + +function createLoggerMock(): PinoLogger { + return { + info: jest.fn(), + setContext: jest.fn(), + } as unknown as PinoLogger; +} + +describe("InvalidateSessionUseCase", () => { + it("invalidates an active session", async () => { + const session = Session.start({ + accountId: "account-1", + expiresAt: new Date(Date.now() + 60_000), + id: "session-1", + jti: "jti-1", + now: new Date(), + userId: "user-1", + }); + const sessionRepository = { + findById: jest.fn().mockResolvedValue(session), + update: jest.fn().mockResolvedValue(undefined), + }; + + const useCase = new InvalidateSessionUseCase( + sessionRepository as never, + { + verify: jest.fn().mockResolvedValue({ + aid: "account-1", + jti: "jti-1", + sid: "session-1", + sub: "user-1", + }), + } as never, + createLoggerMock(), + ); + + await useCase.execute("jwt-token"); + + expect(sessionRepository.update).toHaveBeenCalledTimes(1); + }); + + it("rejects an already revoked session", async () => { + const session = Session.restore({ + accountId: "account-1", + createdAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + id: "session-1", + jti: "jti-1", + revokedAt: new Date(), + status: "revoked", + updatedAt: new Date(), + userId: "user-1", + }); + + const useCase = new InvalidateSessionUseCase( + { + findById: jest.fn().mockResolvedValue(session), + } as never, + { + verify: jest.fn().mockResolvedValue({ + aid: "account-1", + jti: "jti-1", + sid: "session-1", + sub: "user-1", + }), + } as never, + createLoggerMock(), + ); + + await expect(useCase.execute("jwt-token")).rejects.toThrow(InvalidAccessTokenError); + }); +}); diff --git a/test/unit/modules/identity/application/login-with-password.use-case.spec.ts b/test/unit/modules/identity/application/login-with-password.use-case.spec.ts new file mode 100644 index 0000000..a593cee --- /dev/null +++ b/test/unit/modules/identity/application/login-with-password.use-case.spec.ts @@ -0,0 +1,170 @@ +import type { PinoLogger } from "nestjs-pino"; + +import type { ApplicationConfigService } from "../../../../../src/bootstrap/config/application-config.service"; +import { LoginWithPasswordUseCase } from "../../../../../src/modules/identity/application/use-cases/login-with-password.use-case"; +import { Account } from "../../../../../src/modules/identity/domain/entities/account.entity"; +import { Credential } from "../../../../../src/modules/identity/domain/entities/credential.entity"; +import { InvalidCredentialsError } from "../../../../../src/modules/identity/domain/identity.errors"; +import { EmailAddress } from "../../../../../src/modules/identity/domain/value-objects/email-address.value-object"; + +function createLoggerMock(): PinoLogger { + return { + info: jest.fn(), + setContext: jest.fn(), + warn: jest.fn(), + } as unknown as PinoLogger; +} + +function createConfigService(): ApplicationConfigService { + return { + auth: { + jwtExpiresInMinutes: 30, + jwtSecret: "test-secret", + }, + } as ApplicationConfigService; +} + +describe("LoginWithPasswordUseCase", () => { + it("logs in with valid credentials", async () => { + const account = Account.create({ + email: EmailAddress.create("jane@example.com"), + id: "account-1", + now: new Date("2026-03-25T12:00:00.000Z"), + userId: "user-1", + }); + + const credential = Credential.create({ + accountId: account.id, + now: new Date("2026-03-25T12:00:00.000Z"), + passwordHash: "argon2-hash", + }); + + const sessionRepository = { + save: jest.fn().mockResolvedValue(undefined), + }; + + const useCase = new LoginWithPasswordUseCase( + { + findByEmail: jest.fn().mockResolvedValue(account), + } as never, + { + findByAccountId: jest.fn().mockResolvedValue(credential), + } as never, + sessionRepository as never, + { + verify: jest.fn().mockResolvedValue(true), + } as never, + { + issue: jest.fn().mockResolvedValue("jwt-token"), + } as never, + { + getUserById: jest.fn().mockResolvedValue({ + fullName: "Jane Doe", + status: "active", + userId: "user-1", + }), + } as never, + createConfigService(), + createLoggerMock(), + ); + + const result = await useCase.execute({ + email: "jane@example.com", + password: "Password123", + }); + + expect(sessionRepository.save).toHaveBeenCalledTimes(1); + expect(result.accessToken).toBe("jwt-token"); + expect(result.principal.email).toBe("jane@example.com"); + }); + + it("fails with generic invalid credentials for a bad password", async () => { + const account = Account.create({ + email: EmailAddress.create("jane@example.com"), + id: "account-1", + now: new Date(), + userId: "user-1", + }); + + const useCase = new LoginWithPasswordUseCase( + { + findByEmail: jest.fn().mockResolvedValue(account), + } as never, + { + findByAccountId: jest.fn().mockResolvedValue( + Credential.create({ + accountId: account.id, + now: new Date(), + passwordHash: "argon2-hash", + }), + ), + } as never, + {} as never, + { + verify: jest.fn().mockResolvedValue(false), + } as never, + {} as never, + { + getUserById: jest.fn().mockResolvedValue({ + fullName: "Jane Doe", + status: "active", + userId: "user-1", + }), + } as never, + createConfigService(), + createLoggerMock(), + ); + + await expect( + useCase.execute({ + email: "jane@example.com", + password: "bad-password", + }), + ).rejects.toThrow(InvalidCredentialsError); + }); + + it("fails when the user is inactive", async () => { + const account = Account.create({ + email: EmailAddress.create("jane@example.com"), + id: "account-1", + now: new Date(), + userId: "user-1", + }); + + const useCase = new LoginWithPasswordUseCase( + { + findByEmail: jest.fn().mockResolvedValue(account), + } as never, + { + findByAccountId: jest.fn().mockResolvedValue( + Credential.create({ + accountId: account.id, + now: new Date(), + passwordHash: "argon2-hash", + }), + ), + } as never, + {} as never, + { + verify: jest.fn().mockResolvedValue(true), + } as never, + {} as never, + { + getUserById: jest.fn().mockResolvedValue({ + fullName: "Jane Doe", + status: "inactive", + userId: "user-1", + }), + } as never, + createConfigService(), + createLoggerMock(), + ); + + await expect( + useCase.execute({ + email: "jane@example.com", + password: "Password123", + }), + ).rejects.toThrow(InvalidCredentialsError); + }); +}); diff --git a/test/unit/modules/identity/domain/credential-policy.service.spec.ts b/test/unit/modules/identity/domain/credential-policy.service.spec.ts new file mode 100644 index 0000000..3f55923 --- /dev/null +++ b/test/unit/modules/identity/domain/credential-policy.service.spec.ts @@ -0,0 +1,16 @@ +import { + WeakPasswordError, +} from "../../../../../src/modules/identity/domain/identity.errors"; +import { CredentialPolicyService } from "../../../../../src/modules/identity/domain/services/credential-policy.service"; + +describe("CredentialPolicyService", () => { + const service = new CredentialPolicyService(); + + it("accepts a strong password", () => { + expect(() => service.ensurePasswordIsAllowed("Password123")).not.toThrow(); + }); + + it("rejects a weak password", () => { + expect(() => service.ensurePasswordIsAllowed("password")).toThrow(WeakPasswordError); + }); +}); diff --git a/test/unit/modules/identity/domain/email-address.value-object.spec.ts b/test/unit/modules/identity/domain/email-address.value-object.spec.ts new file mode 100644 index 0000000..703402e --- /dev/null +++ b/test/unit/modules/identity/domain/email-address.value-object.spec.ts @@ -0,0 +1,17 @@ +import { + InvalidEmailError, +} from "../../../../../src/modules/identity/domain/identity.errors"; +import { EmailAddress } from "../../../../../src/modules/identity/domain/value-objects/email-address.value-object"; + +describe("EmailAddress", () => { + it("normalizes a valid email", () => { + const email = EmailAddress.create(" USER@Example.COM "); + + expect(email.value).toBe("USER@Example.COM"); + expect(email.normalized).toBe("user@example.com"); + }); + + it("rejects an invalid email", () => { + expect(() => EmailAddress.create("not-an-email")).toThrow(InvalidEmailError); + }); +}); diff --git a/test/unit/modules/identity/domain/session.entity.spec.ts b/test/unit/modules/identity/domain/session.entity.spec.ts new file mode 100644 index 0000000..7badb0c --- /dev/null +++ b/test/unit/modules/identity/domain/session.entity.spec.ts @@ -0,0 +1,39 @@ +import { + SessionAlreadyInvalidatedError, +} from "../../../../../src/modules/identity/domain/identity.errors"; +import { Session } from "../../../../../src/modules/identity/domain/entities/session.entity"; + +describe("Session", () => { + it("creates and invalidates a session", () => { + const now = new Date("2026-03-25T12:00:00.000Z"); + const session = Session.start({ + accountId: "account-1", + expiresAt: new Date("2026-03-25T13:00:00.000Z"), + id: "session-1", + jti: "jti-1", + now, + userId: "user-1", + }); + + const revoked = session.revoke(new Date("2026-03-25T12:30:00.000Z")); + + expect(revoked.status).toBe("revoked"); + expect(revoked.revokedAt?.toISOString()).toBe("2026-03-25T12:30:00.000Z"); + }); + + it("rejects invalidating an already revoked session", () => { + const revoked = Session.restore({ + accountId: "account-1", + createdAt: new Date("2026-03-25T12:00:00.000Z"), + expiresAt: new Date("2026-03-25T13:00:00.000Z"), + id: "session-1", + jti: "jti-1", + revokedAt: new Date("2026-03-25T12:30:00.000Z"), + status: "revoked", + updatedAt: new Date("2026-03-25T12:30:00.000Z"), + userId: "user-1", + }); + + expect(() => revoked.revoke(new Date())).toThrow(SessionAlreadyInvalidatedError); + }); +}); diff --git a/test/unit/modules/users/user.entity.spec.ts b/test/unit/modules/users/user.entity.spec.ts new file mode 100644 index 0000000..67fd94d --- /dev/null +++ b/test/unit/modules/users/user.entity.spec.ts @@ -0,0 +1,32 @@ +import { + InvalidUserFullNameError, + User, +} from "../../../../src/modules/users/domain/entities/user.entity"; + +describe("User", () => { + it("creates an active user with a normalized full name", () => { + const now = new Date("2026-03-25T12:00:00.000Z"); + + const user = User.create({ + fullName: " Jane Doe ", + id: "user-1", + now, + }); + + expect(user).toMatchObject({ + fullName: "Jane Doe", + id: "user-1", + status: "active", + }); + }); + + it("rejects an invalid full name", () => { + expect(() => + User.create({ + fullName: " ", + id: "user-1", + now: new Date(), + }), + ).toThrow(InvalidUserFullNameError); + }); +});