From 240eff481a1670c3960a1779058acd06f1c2a316 Mon Sep 17 00:00:00 2001 From: alexleboucher Date: Sun, 16 Mar 2025 20:32:52 +0100 Subject: [PATCH 1/3] Clean repo files --- .github/ISSUE_TEMPLATE/bug_report.md | 25 ---- .github/ISSUE_TEMPLATE/feature_request.md | 20 --- .../readme-improvement-or-correction.md | 11 -- .github/pull_request_template.md | 14 -- .github/workflows/main-tests.yml | 70 ---------- .github/workflows/pull-request.yml | 5 - CODE_OF_CONDUCT.md | 128 ------------------ CONTRIBUTING.md | 45 ------ repo-cover.png | Bin 58251 -> 0 bytes 9 files changed, 318 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/ISSUE_TEMPLATE/readme-improvement-or-correction.md delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/main-tests.yml delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 repo-cover.png diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 21d453e..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG] • " -labels: bug -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Operating system:** - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 45e0580..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "[FEATURE] • " -labels: feature -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/readme-improvement-or-correction.md b/.github/ISSUE_TEMPLATE/readme-improvement-or-correction.md deleted file mode 100644 index 07ed724..0000000 --- a/.github/ISSUE_TEMPLATE/readme-improvement-or-correction.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: README improvement or correction -about: Suggest a README improvement or correction -title: "[README] • " -labels: documentation -assignees: '' - ---- - -**Describe what changes you would like** -A clear and concise description of what you would like to change in the README diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 0242da9..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,14 +0,0 @@ -## :speech_balloon: Describe your changes - - -## :dna: Type of change -- [ ] Bug fix -- [ ] New feature -- [ ] Documentation update -- [ ] Packages upgrade - -## :white_check_mark: Checklist before requesting a review -- [ ] I have performed a self-review of my code -- [ ] I added the required tests (E2E/units) - -## :bulb: Additional informations \ No newline at end of file diff --git a/.github/workflows/main-tests.yml b/.github/workflows/main-tests.yml deleted file mode 100644 index 39d13fc..0000000 --- a/.github/workflows/main-tests.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Update main code coverage -run-name: Testing main to update code coverage 🧪 - -on: - push: - branches: - - main - workflow_dispatch: - -env: - DB_USER: postgres - DB_PASSWORD: secret - TEST_DB_HOST: localhost - TEST_DB_NAME: test_db - TEST_DB_PORT: 5432 - -jobs: - e2e-tests: - name: E2E tests - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_PORT: ${{ env.TEST_DB_PORT }} - POSTGRES_DB: ${{ env.TEST_DB_NAME }} - POSTGRES_USER: ${{ env.DB_USER }} - POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout repository code - uses: actions/checkout@v3 - - - name: Setup node version - uses: actions/setup-node@v3 - with: - node-version-file: '.node-version' - cache: 'yarn' - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - - name: Cache node modules - uses: actions/cache@v4 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Run E2E tests - run: yarn test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 9734648..afb53db 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -107,8 +107,3 @@ jobs: - name: Run E2E tests run: yarn test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 22421ce..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -dev.alexisleboucher@gmail.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 8d66207..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,45 +0,0 @@ -# Contributing to the boilerplate - -Anyone and everyone is welcome to contribute. - -Please take a moment to review this document in order to make the contribution -process easy and effective for everyone involved. - -Following these guidelines helps to communicate that you respect the time of -the developers managing and developing this open source project. In return, -they should reciprocate that respect in addressing your issue or assessing -patches and features. - -## Issue report -Before opening a pull request, please check if an [issue](https://github.com/alexleboucher/docker-express-postgres-boilerplate/issues) already exists. Otherwise, [open an issue](https://github.com/alexleboucher/docker-express-postgres-boilerplate/issues/new/choose). - -A good bug report shouldn't leave others needing to chase you up for more -information. Please try to be as detailed as possible in your report. What is -your environment? What steps will reproduce the issue? What would you expect -to be the outcome? All these details will help people to fix any potential bugs. - -## Feature requests - -Feature requests are welcome. But take a moment to find out whether your idea -fits with the scope and aims of the project. It's up to _you_ to make a strong -case to convince the project's developers of the merits of this feature. Please -provide as much detail and context as possible. - -## Pull requests - -Good pull requests - patches, improvements, new features - are a fantastic -help. They should remain focused in scope and avoid containing unrelated -commits. - -**Please ask first** before embarking on any significant pull request (e.g. -implementing features, refactoring code, porting to a different language), -otherwise you risk spending a lot of time working on something that the -project's developers might not want to merge into the project. - -Please adhere to the coding conventions used throughout a project (indentation, -accurate comments, etc.) and any other requirements (such as test coverage). - - - -**IMPORTANT**: By submitting a patch, you agree to allow the project -owners to license your work under the terms of the [MIT License](LICENSE.txt). \ No newline at end of file diff --git a/repo-cover.png b/repo-cover.png deleted file mode 100644 index af598a88014978250d53037d2d358a55c26bbd0b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58251 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 Date: Sun, 16 Mar 2025 20:45:32 +0100 Subject: [PATCH 2/3] Remove old information from README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 308c836..4125e14 100644 --- a/README.md +++ b/README.md @@ -550,7 +550,7 @@ There are 3 workflows: 2. The workflow `main-tests` is triggered when code is merged or pushed on main. It runs the tests and sends the coverage to [Codecov](https://about.codecov.io/). It has coverage for the main branch. If you don't want to keep it, you can delete the file `main-tests.yml` in the folder `workflows`. -If you want to keep the tests on pull request but don't want to use Codecov, you can delete `main-tests` and only delete the last step `Upload coverage to Codecov` in `pull-request.yml`. You can also delete `codecov.yml`.
+If you want to keep the tests on pull request but don't want to use Codecov, you can delete `main-tests` and only delete the last step `Upload coverage to Codecov` in `pull-request.yml`.
But if you want to use CodeCov, the only thing you need to do is set your `CODECOV_TOKEN` in your github secrets. 3. The workflow `main-build` is triggered when something is merged or pulled on main. It builds the project and its primary goal is to check if main is building. If you don't want to keep it, you can delete the file `main-build.yml` in the folder `workflows`. From fdb25aa8aca22cadfc4a42ef491a2b9d728bdee8 Mon Sep 17 00:00:00 2001 From: alexleboucher Date: Tue, 18 Mar 2025 12:57:51 +0100 Subject: [PATCH 3/3] Replace session authentication by JWT authentication --- .env.example | 1 + .github/workflows/pull-request.yml | 1 + README.md | 52 ++--- package.json | 9 +- src/app/controllers/auth/auth-controller.ts | 7 - .../middlewares/authenticated-middleware.ts | 12 +- .../middlewares/current-user-middleware.ts | 40 ++++ .../auth/commands/login-request-handler.ts | 64 +++--- .../auth/commands/logout-request-handler.ts | 30 --- .../queries/authenticated-request-handler.ts | 15 +- .../commands/create-user-request-handler.ts | 4 +- src/app/server.ts | 12 +- src/container/middlewares/container.ts | 11 + src/container/middlewares/di-types.ts | 1 + src/container/request-handlers/container.ts | 4 - src/container/request-handlers/di-types.ts | 1 - src/container/services/container.ts | 46 ++--- src/container/services/di-types.ts | 1 - src/container/use-cases/container.ts | 20 +- src/container/use-cases/di-types.ts | 2 + .../services/auth/authenticator.interface.ts | 23 ++- .../auth/session-manager.interface.ts | 4 - .../auth/get-current-user-use-case.ts | 47 +++++ src/domain/use-cases/auth/login-use-case.ts | 51 +++++ .../use-cases/user/create-user-use-case.ts | 20 +- .../auth/authenticator/jwt-authenticator.ts | 59 ++++++ .../authenticator/passport-authenticator.ts | 106 ---------- .../auth/session/express-session-manager.ts | 55 ----- src/tests/e2e/auth/authenticated.test.ts | 4 +- src/tests/e2e/auth/login.test.ts | 9 +- src/tests/e2e/auth/logout.test.ts | 29 --- src/tests/helpers/test-environment.ts | 19 +- .../authenticated-middleware.test.ts | 18 +- .../current-user-middleware.test.ts | 119 +++++++++++ .../commands/login-request-handler.test.ts | 58 +++--- .../commands/logout-request-handler.test.ts | 22 -- .../authenticated-request-handler.test.ts | 17 +- .../create-user-request-handler.test.ts | 2 +- .../auth/get-current-user-use-case.test.ts | 86 ++++++++ .../use-cases/auth/login-use-case.test.ts | 66 ++++++ .../user/create-user-use-case.test.ts | 8 +- yarn.lock | 192 +++++++++--------- 42 files changed, 767 insertions(+), 580 deletions(-) create mode 100644 src/app/middlewares/current-user-middleware.ts delete mode 100644 src/app/request-handlers/auth/commands/logout-request-handler.ts delete mode 100644 src/domain/services/auth/session-manager.interface.ts create mode 100644 src/domain/use-cases/auth/get-current-user-use-case.ts create mode 100644 src/domain/use-cases/auth/login-use-case.ts create mode 100644 src/infra/auth/authenticator/jwt-authenticator.ts delete mode 100644 src/infra/auth/authenticator/passport-authenticator.ts delete mode 100644 src/infra/auth/session/express-session-manager.ts delete mode 100644 src/tests/e2e/auth/logout.test.ts create mode 100644 src/tests/unit/app/middlewares/current-user-middleware.test.ts delete mode 100644 src/tests/unit/app/request-handlers/auth/commands/logout-request-handler.test.ts create mode 100644 src/tests/unit/domain/use-cases/auth/get-current-user-use-case.test.ts create mode 100644 src/tests/unit/domain/use-cases/auth/login-use-case.test.ts diff --git a/.env.example b/.env.example index 2cb243b..c1072d7 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ DB_PORT=5432 DB_HOST_PORT=5433 CORS_ORIGIN_ALLOWED= LOGGER_TYPE=console +JWT_SECRET=secret TEST_DB_HOST=db_test TEST_DB_NAME=test_db diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index afb53db..481bea0 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -19,6 +19,7 @@ env: TEST_DB_HOST: localhost TEST_DB_NAME: test_db TEST_DB_PORT: 5432 + JWT_SECRET: secret jobs: type-check-lint-and-build: diff --git a/README.md b/README.md index 4125e14..e1f2764 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

- A modern boilerplate for building scalable and maintainable REST APIs with authentication, written in TypeScript. It features Docker, Express, TypeORM, Passport, and integrates Clean Architecture principles with Dependency Injection powered by Inversify.
+ A modern boilerplate for building scalable and maintainable REST APIs with authentication, written in TypeScript. It features Docker, Express, TypeORM, jsonwebtoken for authentication by JWT, and integrates Clean Architecture principles with Dependency Injection powered by Inversify.
Made with ❤️ by Alex Le Boucher and contributors

@@ -35,7 +35,7 @@ It integrates common features such as: - Docker containerization - Database connection (PostgreSQL with TypeORM) -- Authentication (using Passport) +- Authentication (using jsonwebtoken) - Centralized error handling - Clean Architecture principles for better separation of concerns - Dependency Injection powered by Inversify for modular and testable code @@ -54,8 +54,7 @@ Packages are frequently upgraded. You can easily see the packages version status ## Features - **Docker containerization** to easily run your code anywhere and avoid installing tools like PostgreSQL on your computer. -- **Authentication** with [Passport](https://www.passportjs.org/). -- **Authentication session** thanks to [express-session](https://github.com/expressjs/session) and [connect-pg-simple](https://github.com/voxpelli/node-connect-pg-simple). +- **Authentication by JWT** with [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken). - **Simplified Database Query** managed by [TypeORM](https://github.com/typeorm/typeorm). - **Object-oriented database model** with [TypeORM](https://github.com/typeorm/typeorm) entities. - **Integrated Testing Tools** with [Jest](https://jestjs.io/fr/docs/getting-started). @@ -211,7 +210,6 @@ The project contains Github templates and workflows. If you don't want to keep t | GET | `/health` | Retures the server health status | None. | | POST | `/users` | Creates a new user. | `username` (min. 5 chars), `email` (valid), `password` (min. 8 chars). | | POST | `/auth/login` | Logs in a user. | `email` and `password`. | -| POST | `/auth/logout` | Logs out the currently authenticated user. | None. | | GET | `/auth/authenticated` | Returns the user authentication status | None. | --- @@ -237,7 +235,7 @@ The project contains Github templates and workflows. If you don't want to keep t | **src/domain/services/** | Interfaces for domain-level services (e.g., authentication, encryption). | | **src/domain/use-cases/** | Use cases implementing business logic. | | **src/infra/** | Infrastructure layer providing implementations for core and domain abstractions. | -| **src/infra/auth/** | Authentication implementations using Passport.js and session management. | +| **src/infra/auth/** | Authentication implementations | | **src/infra/database/** | Database configuration, models, and migrations. | | **src/infra/database/repositories/** | Concrete implementations of domain repository interfaces using TypeORM. | | **src/infra/id-generator/** | UUID-based ID generator. | @@ -267,14 +265,9 @@ The project contains Github templates and workflows. If you don't want to keep t | TEST_DB_NAME | Test database name. | ❌ | | | TEST_DB_PORT | Test database host port. | ❌ | | | TEST_DB_HOST_PORT | Test database mapped port for accessing the test database in Docker. | ❌ | | +| JWT_SECRET | Secret used to encryot JSON web tokens. | ❌ | | +| JWT_EXPIRES_IN_SECONDS | Number of seconds before JWT tokens expire. | ✔️ | 86400 | | CORS_ORIGIN_ALLOWED | List of allowed origins for CORS. | ✔️ | * | -| SESSION_SECRET | Secret key for signing the session ID cookie. | ✔️ | session-secret | -| SESSION_RESAVE | Forces the session to be saved back to the session store, even if it was never modified. | ✔️ | false | -| SESSION_SAVE_UNINITIALIZED | Forces an uninitialized session to be saved to the store. | ✔️ | false | -| SESSION_COOKIE_SECURE | Ensures the cookie is only sent over HTTPS. | ✔️ | false | -| SESSION_COOKIE_MAX_AGE | Lifetime of the session cookie in milliseconds. | ✔️ | 7776000000 (90 days) | -| SESSION_COOKIE_HTTP_ONLY | Ensures the cookie is inaccessible to JavaScript (for XSS protection). | ✔️ | false | -| SESSION_COOKIE_SAME_SITE | Controls whether the cookie is sent with cross-site requests. | ✔️ | lax | | DB_LOGGING | Enables or disables query logging in TypeORM. | ✔️ | false | | TYPEORM_ENTITIES | Path to TypeORM entity files. | ✔️ | src/infra/database/models/**/*.entity.ts | | TYPEORM_MIGRATIONS | Path to TypeORM migration files. | ✔️ | src/infra/database/migrations/**/*.ts | @@ -284,16 +277,7 @@ The project contains Github templates and workflows. If you don't want to keep t ## Authentication -This boilerplate uses `Passport.js` to handle authentication. `Passport.js` is a powerful, flexible, and modular middleware that allows you to implement various authentication strategies, including social logins (e.g., Google, Facebook, GitHub, etc.). - -### Configuration - -The configuration for `Passport` is located in `src/infra/auth/authenticator/passport-authenticator.ts`. This class centralizes the setup of strategies and the implementation of required methods like `serializeUser` and `deserializeUser`. - -- **`serializeUser`**: Defines what data should be stored in the session. By default, it stores the user ID. -- **`deserializeUser`**: Fetches user information based on the session data and assigns it to `req.user`. This makes the authenticated user readily accessible via `req.user` without requiring additional calls. - -You can find detailed documentation on `Passport.js` [here](https://www.passportjs.org/). +This boilerplate uses JSON Web Tokens to handle authentication with `jsonwebtoken`. ### Route Protection @@ -303,22 +287,14 @@ To ensure route security and verify the user's authentication status, this boile This middleware ensures the user is authenticated before allowing access to the route. It integrates seamlessly with the controllers, as shown in the example below: ```typescript -@httpPost('/logout', MIDDLEWARES_DI_TYPES.AuthenticatedMiddleware) -public logout(): void { - // Logout logic here +@httpPost('/your-protected-route', MIDDLEWARES_DI_TYPES.AuthenticatedMiddleware) +public yourProtectedRoute(): void { + // yourProtectedRoute logic here } ``` This pattern allows you to secure endpoints declaratively and keeps the authentication logic consistent throughout the project. -### Extending Authentication Strategies -Adding new strategies is straightforward thanks to Passport's modular design. To include a new strategy: - -1. Install the corresponding Passport strategy package (e.g., `passport-google-oauth20`). -2. Configure the strategy in `passport-authenticator.ts` by adding it to the existing strategies. - -This design simplifies the addition of new authentication methods and scales well as your application grows. - --- ## Migrations @@ -458,10 +434,10 @@ Use `test` to define individual tests within `describe` blocks: ``` 4. **Authenticated Requests:** -For tests requiring user authentication, create an authenticated agent: +For tests requiring user authentication, create an authenticated request: ```typescript - const { agent } = await testEnv.createAuthenticatedAgent(); - const res = await agent.get('/auth/authenticated'); + const request = await testEnv.authenticatedRequest(); + const res = await request.get('/auth/authenticated'); expect(res.body).toEqual({ authenticated: true }); ``` @@ -573,7 +549,7 @@ You can see the upcoming or in progress features [here](https://github.com/users | --------------------------------- | --------------------------------- | | [Express](https://expressjs.com/) | Express is a minimal and flexible Node.js web application framework that provides a robust set of features for web and mobile applications. | | [TypeORM](http://typeorm.io/#/) | TypeORM is highly influenced by other ORMs, such as Hibernate, Doctrine and Entity Framework. | -| [Passport](https://www.passportjs.org/) | Passport is authentication middleware for Node.js. Extremely flexible and modular, Passport can be unobtrusively dropped in to any Express-based web application. | +| [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) | An implementation of JSON Web Tokens for Node.js that helps you securely transmit information between parties as a JSON object. | | [Docker](https://www.docker.com/) | Docker is a platform designed to help developers build, share, and run modern applications. We handle the tedious setup, so you can focus on the code. | | [PostgreSQL](https://www.postgresql.org/) | PostgreSQL is a powerful, open source object-relational database system with over 35 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance. | | [TypeScript](https://www.typescriptlang.org/) | TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale. | diff --git a/package.json b/package.json index ce7933b..08ae553 100644 --- a/package.json +++ b/package.json @@ -27,17 +27,14 @@ }, "dependencies": { "bcryptjs": "2.4.3", - "connect-pg-simple": "10.0.0", "cors": "2.8.5", "dotenv": "16.4.7", "express": "4.21.2", - "express-session": "1.18.1", "helmet": "8.0.0", "inversify": "6.2.1", "inversify-express-utils": "6.4.10", + "jsonwebtoken": "^9.0.2", "morgan": "1.10.0", - "passport": "0.7.0", - "passport-local": "1.0.0", "pg": "8.13.1", "reflect-metadata": "0.2.2", "tslib": "2.8.1", @@ -53,12 +50,10 @@ "@types/connect-pg-simple": "7.0.3", "@types/cors": "2.8.17", "@types/express": "4.17.21", - "@types/express-session": "1.18.1", "@types/jest": "29.5.14", + "@types/jsonwebtoken": "^9.0.9", "@types/morgan": "1.9.9", "@types/node": "22.10.3", - "@types/passport": "1.0.17", - "@types/passport-local": "1.0.38", "@types/supertest": "6.0.2", "eslint": "9.17.0", "eslint-import-resolver-typescript": "3.7.0", diff --git a/src/app/controllers/auth/auth-controller.ts b/src/app/controllers/auth/auth-controller.ts index a24e889..92022fe 100644 --- a/src/app/controllers/auth/auth-controller.ts +++ b/src/app/controllers/auth/auth-controller.ts @@ -4,7 +4,6 @@ import type { NextFunction, Request, Response } from 'express'; import type { IRequestHandler } from '@/app/request-handlers/request-handler.interface'; import { REQUEST_HANDLERS_DI_TYPES } from '@/container/request-handlers/di-types'; -import { MIDDLEWARES_DI_TYPES } from '@/container/middlewares/di-types'; // The auth routes are a bit different from the other routes in the way that they don't call a use case // but the authenticator directly because req, res and next are needed and it's not the responsibility @@ -13,7 +12,6 @@ import { MIDDLEWARES_DI_TYPES } from '@/container/middlewares/di-types'; export class AuthController extends BaseHttpController { constructor( @inject(REQUEST_HANDLERS_DI_TYPES.LoginRequestHandler) private readonly loginRequestHandler: IRequestHandler, - @inject(REQUEST_HANDLERS_DI_TYPES.LogoutRequestHandler) private readonly logoutRequestHandler: IRequestHandler, @inject(REQUEST_HANDLERS_DI_TYPES.AuthenticatedRequestHandler) private readonly authenticatedRequestHandler: IRequestHandler, ) { super(); @@ -28,9 +26,4 @@ export class AuthController extends BaseHttpController { public async login(req: Request, res: Response, next: NextFunction) { return this.loginRequestHandler.handle(req, res, next); } - - @httpPost('/logout', MIDDLEWARES_DI_TYPES.AuthenticatedMiddleware) - public async logout(req: Request, res: Response, next: NextFunction) { - return this.logoutRequestHandler.handle(req, res, next); - } } \ No newline at end of file diff --git a/src/app/middlewares/authenticated-middleware.ts b/src/app/middlewares/authenticated-middleware.ts index 836bce9..075ee71 100644 --- a/src/app/middlewares/authenticated-middleware.ts +++ b/src/app/middlewares/authenticated-middleware.ts @@ -1,21 +1,13 @@ -import { inject, injectable } from 'inversify'; +import { injectable } from 'inversify'; import { BaseMiddleware } from 'inversify-express-utils'; import type { NextFunction, Request, Response } from 'express'; -import { SERVICES_DI_TYPES } from '@/container/services/di-types'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; import { HttpError } from '@/app/http-error'; @injectable() export class AuthenticatedMiddleware extends BaseMiddleware { - constructor( - @inject(SERVICES_DI_TYPES.Authenticator) private readonly authenticator: IAuthenticator, - ) { - super(); - } - handler(req: Request, res: Response, next: NextFunction) { - if (!this.authenticator.isAuthenticated(req)) { + if (!req.user) { throw HttpError.forbidden('User must be authenticated'); } diff --git a/src/app/middlewares/current-user-middleware.ts b/src/app/middlewares/current-user-middleware.ts new file mode 100644 index 0000000..465b962 --- /dev/null +++ b/src/app/middlewares/current-user-middleware.ts @@ -0,0 +1,40 @@ +import type { NextFunction, Request, Response } from 'express'; +import { inject, injectable } from 'inversify'; + +import { USE_CASES_DI_TYPES } from '@/container/use-cases/di-types'; +import type { IUseCase } from '@/core/use-case/use-case.interface'; +import type { GetCurrentUserUseCaseFailure, GetCurrentUserUseCasePayload, GetCurrentUserUseCaseSuccess } from '@/domain/use-cases/auth/get-current-user-use-case'; + +export interface ICurrentUserMiddleware { + handler: (req: Request, res: Response, next: NextFunction) => Promise; +} + +@injectable() +export class CurrentUserMiddleware implements ICurrentUserMiddleware { + constructor( + @inject(USE_CASES_DI_TYPES.GetCurrentUserUseCase) private readonly getCurrentUserUseCase: IUseCase, + ) {} + + async handler(req: Request, res: Response, next: NextFunction) { + try { + req.user = null; + + const authHeader = req.headers.authorization; + + if (authHeader) { + const [type, token] = authHeader.split(' '); + + if (type === 'Bearer' && token) { + const result = await this.getCurrentUserUseCase.execute({ token }); + if (result.isSuccess()) { + req.user = result.value.user; + } + } + } + + next(); + } catch (error) { + next(error); + } + } +} \ No newline at end of file diff --git a/src/app/request-handlers/auth/commands/login-request-handler.ts b/src/app/request-handlers/auth/commands/login-request-handler.ts index 8c3f044..7938405 100644 --- a/src/app/request-handlers/auth/commands/login-request-handler.ts +++ b/src/app/request-handlers/auth/commands/login-request-handler.ts @@ -4,13 +4,17 @@ import type { Request, Response, NextFunction } from 'express'; import type { IRequestHandler } from '@/app/request-handlers/request-handler.interface'; import { HttpError } from '@/app/http-error'; -import { SERVICES_DI_TYPES } from '@/container/services/di-types'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; +import { USE_CASES_DI_TYPES } from '@/container/use-cases/di-types'; +import type { IUseCase } from '@/core/use-case/use-case.interface'; +import type { LoginUseCaseFailure, LoginUseCasePayload, LoginUseCaseSuccess } from '@/domain/use-cases/auth/login-use-case'; type ResponseBody = { - id: string; - username: string; - email: string; + user: { + id: string; + username: string; + email: string; + }; + token: string; }; const payloadSchema = z.object({ @@ -18,36 +22,42 @@ const payloadSchema = z.object({ password: z.string(), }); -// This handler, like the other auth handlers, is a bit special because it doesn't -// execute a use case. Instead, it interacts directly with the authenticator, as -// it requires access to req, res, and next, which are not the responsibility of the domain layer. @injectable() export class LoginRequestHandler implements IRequestHandler { constructor( - @inject(SERVICES_DI_TYPES.Authenticator) private readonly authenticator: IAuthenticator, + @inject(USE_CASES_DI_TYPES.LoginUseCase) private readonly loginUseCase: IUseCase, ) {} async handle(req: Request, res: Response, next: NextFunction) { // We don't need to get the values because the authenticator will handle the authentication // but we validate the body to ensure the request is well-formed - payloadSchema.parse(req.body); - - const { err, user } = await this.authenticator.authenticateLocal(req, res, next); - - if (err) { - throw err; - } - - if (!user) { - throw HttpError.unauthorized('Incorrect credentials'); + const { email, password } = payloadSchema.parse(req.body); + + const result = await this.loginUseCase.execute({ email, password }); + + if (result.isSuccess()) { + const { user, token } = result.value; + + const response: ResponseBody = { + user: { + id: user.id, + username: user.username, + email: user.email, + }, + token, + }; + + res.status(200).send(response); + return; + } else if (result.isFailure()) { + const failure = result.failure; + + switch (failure.reason) { + case 'InvalidCredentials': + throw HttpError.unauthorized('Incorrect credentials'); + case 'UnknownError': + throw failure.error; + } } - - const response: ResponseBody = { - id: user.id, - username: user.username, - email: user.email, - }; - - res.send(response); } } \ No newline at end of file diff --git a/src/app/request-handlers/auth/commands/logout-request-handler.ts b/src/app/request-handlers/auth/commands/logout-request-handler.ts deleted file mode 100644 index d5e349c..0000000 --- a/src/app/request-handlers/auth/commands/logout-request-handler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { inject, injectable } from 'inversify'; -import type { Request, Response } from 'express'; - -import type { IRequestHandler } from '@/app/request-handlers/request-handler.interface'; -import { SERVICES_DI_TYPES } from '@/container/services/di-types'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; - -type ResponseBody = { - success: boolean; -}; - -// This handler, like the other auth handlers, is a bit special because it doesn't -// execute a use case. Instead, it interacts directly with the authenticator, as -// it requires access to req, res, and next, which are not the responsibility of the domain layer. -@injectable() -export class LogoutRequestHandler implements IRequestHandler { - constructor( - @inject(SERVICES_DI_TYPES.Authenticator) private readonly authenticator: IAuthenticator, - ) {} - - async handle(req: Request, res: Response) { - await this.authenticator.logout(req); - - const response: ResponseBody = { - success: true, - }; - - res.send(response); - } -} \ No newline at end of file diff --git a/src/app/request-handlers/auth/queries/authenticated-request-handler.ts b/src/app/request-handlers/auth/queries/authenticated-request-handler.ts index 24f4230..b18ff94 100644 --- a/src/app/request-handlers/auth/queries/authenticated-request-handler.ts +++ b/src/app/request-handlers/auth/queries/authenticated-request-handler.ts @@ -1,26 +1,19 @@ -import { inject, injectable } from 'inversify'; +import { injectable } from 'inversify'; import type { Request, Response } from 'express'; -import { SERVICES_DI_TYPES } from '@/container/services/di-types'; import type { IRequestHandler } from '@/app/request-handlers/request-handler.interface'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; type ResponseBody = { authenticated: boolean; }; -// This handler, like the other auth handlers, is a bit special because it doesn't -// execute a use case. Instead, it interacts directly with the authenticator, as -// it requires access to req, res, and next, which are not the responsibility of the domain layer. +// This handler doesn't execute a use case because it's very simple and doesn't need to +// do anything with the domain layer. @injectable() export class AuthenticatedRequestHandler implements IRequestHandler { - constructor( - @inject(SERVICES_DI_TYPES.Authenticator) private readonly authenticator: IAuthenticator, - ) {} - handle(req: Request, res: Response) { res.send({ - authenticated: this.authenticator.isAuthenticated(req), + authenticated: Boolean(req.user), }); } } \ No newline at end of file diff --git a/src/app/request-handlers/users/commands/create-user-request-handler.ts b/src/app/request-handlers/users/commands/create-user-request-handler.ts index 6606148..9ae9bf4 100644 --- a/src/app/request-handlers/users/commands/create-user-request-handler.ts +++ b/src/app/request-handlers/users/commands/create-user-request-handler.ts @@ -4,7 +4,7 @@ import type { Request, Response } from 'express'; import type { IRequestHandler } from '@/app/request-handlers/request-handler.interface'; import type { IUseCase } from '@/core/use-case/use-case.interface'; -import type { CreateUserCaseFailure, CreateUserCasePayload, CreateUserCaseSuccess } from '@/domain/use-cases/user/create-user-use-case'; +import type { CreateUserUseCaseFailure, CreateUserUseCasePayload, CreateUserUseCaseSuccess } from '@/domain/use-cases/user/create-user-use-case'; import { USE_CASES_DI_TYPES } from '@/container/use-cases/di-types'; import { HttpError } from '@/app/http-error'; @@ -23,7 +23,7 @@ const payloadSchema = z.object({ @injectable() export class CreateUserRequestHandler implements IRequestHandler { constructor( - @inject(USE_CASES_DI_TYPES.CreateUserUseCase) private readonly createUserUseCase: IUseCase, + @inject(USE_CASES_DI_TYPES.CreateUserUseCase) private readonly createUserUseCase: IUseCase, ) {} async handle(req: Request, res: Response) { diff --git a/src/app/server.ts b/src/app/server.ts index 1d9a16a..cd49151 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -6,12 +6,10 @@ import morgan from 'morgan'; import { json, urlencoded } from 'express'; import { env } from '@/core/env/env'; -import type { ISessionManager } from '@/domain/services/auth/session-manager.interface'; -import { SERVICES_DI_TYPES } from '@/container/services/di-types'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; import { MIDDLEWARES_DI_TYPES } from '@/container/middlewares/di-types'; import { HttpError } from '@/app/http-error'; import type { IErrorMiddleware } from '@/app/middlewares/error-middleware'; +import type { ICurrentUserMiddleware } from '@/app/middlewares/current-user-middleware'; export const createServer = (container: Container) => { const corsOptions = { @@ -30,12 +28,8 @@ export const createServer = (container: Container) => { app.use(helmet()); app.set('json spaces', 2); - const session = container.get(SERVICES_DI_TYPES.SessionManager); - app.use(session.configure()); - - const authenticator = container.get(SERVICES_DI_TYPES.Authenticator); - authenticator.configure(); - app.use(authenticator.session()); + const currentUserMiddleware = container.get(MIDDLEWARES_DI_TYPES.CurrentUserMiddleware); + app.use(currentUserMiddleware.handler.bind(currentUserMiddleware)); }); server.setErrorConfig((app) => { diff --git a/src/container/middlewares/container.ts b/src/container/middlewares/container.ts index bcc8f7e..667c099 100644 --- a/src/container/middlewares/container.ts +++ b/src/container/middlewares/container.ts @@ -5,6 +5,8 @@ import { MIDDLEWARES_DI_TYPES } from '@/container/middlewares/di-types'; import { AuthenticatedMiddleware } from '@/app/middlewares/authenticated-middleware'; import type { IErrorMiddleware } from '@/app/middlewares/error-middleware'; import { ErrorMiddleware } from '@/app/middlewares/error-middleware'; +import type { ICurrentUserMiddleware } from '@/app/middlewares/current-user-middleware'; +import { CurrentUserMiddleware } from '@/app/middlewares/current-user-middleware'; export const registerMiddlewares = (containerBuilder: ContainerBuilder) => { const builder = new MiddlewaresContainerBuilder(containerBuilder) @@ -21,12 +23,21 @@ class MiddlewaresContainerBuilder { registerMiddlewares() { this + .registerCurrentUserMiddleware() .registerAuthenticatedMiddleware() .registerErrorMiddleware(); return this.containerBuilder; } + private registerCurrentUserMiddleware() { + this.containerBuilder.registerActions.push((container) => { + container.bind(MIDDLEWARES_DI_TYPES.CurrentUserMiddleware).to(CurrentUserMiddleware).inRequestScope(); + }); + + return this; + } + private registerAuthenticatedMiddleware() { this.containerBuilder.registerActions.push((container) => { container.bind(MIDDLEWARES_DI_TYPES.AuthenticatedMiddleware).to(AuthenticatedMiddleware).inRequestScope(); diff --git a/src/container/middlewares/di-types.ts b/src/container/middlewares/di-types.ts index bcd7a84..0ae7939 100644 --- a/src/container/middlewares/di-types.ts +++ b/src/container/middlewares/di-types.ts @@ -1,4 +1,5 @@ export const MIDDLEWARES_DI_TYPES = { AuthenticatedMiddleware: Symbol.for('AuthenticatedMiddleware'), ErrorMiddleware: Symbol.for('ErrorMiddleware'), + CurrentUserMiddleware: Symbol.for('CurrentUserMiddleware'), }; \ No newline at end of file diff --git a/src/container/request-handlers/container.ts b/src/container/request-handlers/container.ts index 5e35041..5f78738 100644 --- a/src/container/request-handlers/container.ts +++ b/src/container/request-handlers/container.ts @@ -1,5 +1,4 @@ import { LoginRequestHandler } from '@/app/request-handlers/auth/commands/login-request-handler'; -import { LogoutRequestHandler } from '@/app/request-handlers/auth/commands/logout-request-handler'; import { AuthenticatedRequestHandler } from '@/app/request-handlers/auth/queries/authenticated-request-handler'; import { HealthRequestHandler } from '@/app/request-handlers/health/queries/health-request-handler'; import type { IRequestHandler } from '@/app/request-handlers/request-handler.interface'; @@ -33,9 +32,6 @@ class RequestHandlersContainerBuilder { this.containerBuilder.registerActions.push((container) => { container.bind(REQUEST_HANDLERS_DI_TYPES.LoginRequestHandler).to(LoginRequestHandler).inSingletonScope(); }); - this.containerBuilder.registerActions.push((container) => { - container.bind(REQUEST_HANDLERS_DI_TYPES.LogoutRequestHandler).to(LogoutRequestHandler).inSingletonScope(); - }); this.containerBuilder.registerActions.push((container) => { container.bind(REQUEST_HANDLERS_DI_TYPES.AuthenticatedRequestHandler).to(AuthenticatedRequestHandler).inSingletonScope(); }); diff --git a/src/container/request-handlers/di-types.ts b/src/container/request-handlers/di-types.ts index 88cd974..44a1ad4 100644 --- a/src/container/request-handlers/di-types.ts +++ b/src/container/request-handlers/di-types.ts @@ -1,6 +1,5 @@ export const REQUEST_HANDLERS_DI_TYPES = { LoginRequestHandler: Symbol.for('LoginRequestHandler'), - LogoutRequestHandler: Symbol.for('LogoutRequestHandler'), AuthenticatedRequestHandler: Symbol.for('AuthenticatedRequestHandler'), CreateUserRequestHandler: Symbol.for('CreateUserRequestHandler'), HealthRequestHandler: Symbol.for('HealthRequestHandler'), diff --git a/src/container/services/container.ts b/src/container/services/container.ts index 4a2da08..5e98ba2 100644 --- a/src/container/services/container.ts +++ b/src/container/services/container.ts @@ -1,12 +1,11 @@ import type { BuildContainerOptions, ContainerBuilder } from '@/container/container'; +import { REPOSITORIES_DI_TYPES } from '@/container/repositories/di-types'; import { SERVICES_DI_TYPES } from '@/container/services/di-types'; -import { integerEnv, mandatoryEnv, mandatoryIntegerEnv, booleanEnv, unionEnv, env } from '@/core/env/env'; +import { integerEnv, mandatoryEnv, mandatoryIntegerEnv, booleanEnv, env } from '@/core/env/env'; +import type { IUserRepository } from '@/domain/repositories/user-repository.interface'; import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; -import type { ISessionManager } from '@/domain/services/auth/session-manager.interface'; import type { IEncryptor } from '@/domain/services/security/encryptor.interface'; -import { PassportAuthenticator } from '@/infra/auth/authenticator/passport-authenticator'; -import type { SessionConfig } from '@/infra/auth/session/express-session-manager'; -import { ExpressSessionManager } from '@/infra/auth/session/express-session-manager'; +import { JwtAuthenticator } from '@/infra/auth/authenticator/jwt-authenticator'; import type { IDatabase, DatabaseConfig } from '@/infra/database/database'; import { Database } from '@/infra/database/database'; import { BcryptEncryptor } from '@/infra/security/encryptor/bcrypt-encryptor'; @@ -46,13 +45,15 @@ class ServicesContainerBuilder { } private registerAuthServices() { - const config = this.getSessionManagerConfig(); - this.containerBuilder.registerActions.push((container) => { - container.bind(SERVICES_DI_TYPES.SessionManager).toDynamicValue(() => new ExpressSessionManager(config)).inSingletonScope(); - }); - + const config = { + secret: mandatoryEnv('JWT_SECRET'), + expiresInSeconds: integerEnv('JWT_EXPIRES_IN_SECONDS', 86400), // 1 day in seconds (default) + }; this.containerBuilder.registerActions.push((container) => { - container.bind(SERVICES_DI_TYPES.Authenticator).to(PassportAuthenticator).inSingletonScope(); + container.bind(SERVICES_DI_TYPES.Authenticator).toDynamicValue(() => new JwtAuthenticator( + config, + container.get(REPOSITORIES_DI_TYPES.UserRepository), + )).inSingletonScope(); }); return this; @@ -77,29 +78,6 @@ class ServicesContainerBuilder { return this; } - private getSessionManagerConfig(): SessionConfig { - const nodeEnv = mandatoryEnv('NODE_ENV'); - - return { - storeConfig: nodeEnv === 'test' ? undefined : { - host: mandatoryEnv('DB_HOST'), - port: mandatoryIntegerEnv('DB_PORT'), - user: mandatoryEnv('DB_USER'), - password: mandatoryEnv('DB_PASSWORD'), - database: mandatoryEnv('DB_NAME'), - }, - secret: env('SESSION_SECRET', 'session-secret'), - resave: booleanEnv('SESSION_RESAVE', false), - saveUninitialized: booleanEnv('SESSION_SAVE_UNINITIALIZED', false), - cookie: { - secure: booleanEnv('SESSION_COOKIE_SECURE', false), - maxAge: integerEnv('SESSION_COOKIE_MAX_AGE', 90 * 24 * 60 * 60 * 1000), // 90 days by default - httpOnly: booleanEnv('SESSION_COOKIE_HTTP_ONLY', false), - sameSite: unionEnv('SESSION_COOKIE_SAME_SITE', ['strict', 'lax', 'none',], 'lax'), - }, - }; - } - private getDatabaseConfig(): DatabaseConfig { const isTest = mandatoryEnv('NODE_ENV') === 'test'; diff --git a/src/container/services/di-types.ts b/src/container/services/di-types.ts index 7f193d7..deede08 100644 --- a/src/container/services/di-types.ts +++ b/src/container/services/di-types.ts @@ -1,6 +1,5 @@ export const SERVICES_DI_TYPES = { Database: Symbol.for('Database'), Encryptor: Symbol.for('Encryptor'), - SessionManager: Symbol.for('SessionManager'), Authenticator: Symbol.for('Authenticator'), }; \ No newline at end of file diff --git a/src/container/use-cases/container.ts b/src/container/use-cases/container.ts index a55c9cf..64bf7d8 100644 --- a/src/container/use-cases/container.ts +++ b/src/container/use-cases/container.ts @@ -1,6 +1,8 @@ import type { ContainerBuilder } from '@/container/container'; import { USE_CASES_DI_TYPES } from '@/container/use-cases/di-types'; import type { IUseCase } from '@/core/use-case/use-case.interface'; +import { GetCurrentUserUseCase } from '@/domain/use-cases/auth/get-current-user-use-case'; +import { LoginUseCase } from '@/domain/use-cases/auth/login-use-case'; import { CreateUserUseCase } from '@/domain/use-cases/user/create-user-use-case'; export const registerUseCases = (containerBuilder: ContainerBuilder) => { @@ -17,12 +19,26 @@ class UseCasesContainerBuilder { constructor(private readonly containerBuilder: ContainerBuilder) {} registerUseCases() { - this.registerCreateUserUseCase(); + this + .registerAuthUseCases() + .registerUserUseCases(); return this.containerBuilder; } - private registerCreateUserUseCase() { + private registerAuthUseCases() { + this.containerBuilder.registerActions.push((container) => { + container.bind(USE_CASES_DI_TYPES.LoginUseCase).to(LoginUseCase).inSingletonScope(); + }); + + this.containerBuilder.registerActions.push((container) => { + container.bind(USE_CASES_DI_TYPES.GetCurrentUserUseCase).to(GetCurrentUserUseCase).inSingletonScope(); + }); + + return this; + } + + private registerUserUseCases() { this.containerBuilder.registerActions.push((container) => { container.bind(USE_CASES_DI_TYPES.CreateUserUseCase).to(CreateUserUseCase).inSingletonScope(); }); diff --git a/src/container/use-cases/di-types.ts b/src/container/use-cases/di-types.ts index 2c0f36f..76e89f8 100644 --- a/src/container/use-cases/di-types.ts +++ b/src/container/use-cases/di-types.ts @@ -1,3 +1,5 @@ export const USE_CASES_DI_TYPES = { CreateUserUseCase: Symbol.for('CreateUserUseCase'), + LoginUseCase: Symbol.for('LoginUseCase'), + GetCurrentUserUseCase: Symbol.for('GetCurrentUserUseCase'), }; \ No newline at end of file diff --git a/src/domain/services/auth/authenticator.interface.ts b/src/domain/services/auth/authenticator.interface.ts index b222ed6..23226ff 100644 --- a/src/domain/services/auth/authenticator.interface.ts +++ b/src/domain/services/auth/authenticator.interface.ts @@ -1,15 +1,22 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { User } from '@/domain/models/user'; export type AuthenticateResponse = { - err?: any; - user?: User | false | null; + success: true; + user: User; + token: string; +} | { + success: false; +}; + +export type GetAuthenticatedUserResponse = { + success: true; + user: User; +} | { + success: false; + reason: 'InvalidToken' | 'UserNotFound'; }; export interface IAuthenticator { - configure: () => void; - session: () => any; - authenticateLocal: (req: any, res: any, next: any) => Promise; - isAuthenticated: (req: any) => boolean; - logout: (req: any) => Promise; + authenticate: (email: string, password: string) => Promise; + getAuthenticatedUser: (token: string) => Promise; } \ No newline at end of file diff --git a/src/domain/services/auth/session-manager.interface.ts b/src/domain/services/auth/session-manager.interface.ts deleted file mode 100644 index 8b35341..0000000 --- a/src/domain/services/auth/session-manager.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export type ISessionManager = { - configure(): any; -}; \ No newline at end of file diff --git a/src/domain/use-cases/auth/get-current-user-use-case.ts b/src/domain/use-cases/auth/get-current-user-use-case.ts new file mode 100644 index 0000000..125e24f --- /dev/null +++ b/src/domain/use-cases/auth/get-current-user-use-case.ts @@ -0,0 +1,47 @@ +import { inject, injectable } from 'inversify'; + +import type { IUseCase } from '@/core/use-case/use-case.interface'; +import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; +import { SERVICES_DI_TYPES } from '@/container/services/di-types'; +import type { User } from '@/domain/models/user'; +import { Failure, Success } from '@/core/result/result'; + +export type GetCurrentUserUseCasePayload = { + token: string; +}; + +type GetCurrentUserUseCaseFailureReason = 'InvalidToken' | 'UserNotFound' | 'UnknownError'; +export type GetCurrentUserUseCaseFailure = { + reason: GetCurrentUserUseCaseFailureReason; + error: Error; +}; + +export type GetCurrentUserUseCaseSuccess = { + user: User; +}; + +@injectable() +export class GetCurrentUserUseCase implements IUseCase { + constructor( + @inject(SERVICES_DI_TYPES.Authenticator) private readonly authenticator: IAuthenticator, + ) {} + + async execute(payload: GetCurrentUserUseCasePayload) { + try { + const result = await this.authenticator.getAuthenticatedUser(payload.token); + if (result.success) { + return new Success({ user: result.user }); + } + + return new Failure({ + reason: result.reason, + error: new Error(`Failed to get current user: ${result.reason}`), + }); + } catch (error) { + return new Failure({ + reason: 'UnknownError', + error: error as Error, + }); + } + } +} \ No newline at end of file diff --git a/src/domain/use-cases/auth/login-use-case.ts b/src/domain/use-cases/auth/login-use-case.ts new file mode 100644 index 0000000..4db3052 --- /dev/null +++ b/src/domain/use-cases/auth/login-use-case.ts @@ -0,0 +1,51 @@ +import { inject, injectable } from 'inversify'; + +import type { IUseCase } from '@/core/use-case/use-case.interface'; +import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; +import { SERVICES_DI_TYPES } from '@/container/services/di-types'; +import type { User } from '@/domain/models/user'; +import { Failure, Success } from '@/core/result/result'; + +export type LoginUseCasePayload = { + email: string; + password: string; +}; + +type LoginUseCaseFailureReason = 'InvalidCredentials' | 'UnknownError'; +export type LoginUseCaseFailure = { + reason: LoginUseCaseFailureReason; + error: Error; +}; + +export type LoginUseCaseSuccess = { + user: User; + token: string; +}; + +@injectable() +export class LoginUseCase implements IUseCase { + constructor( + @inject(SERVICES_DI_TYPES.Authenticator) private readonly authenticator: IAuthenticator, + ) {} + + async execute(payload: LoginUseCasePayload) { + try { + const result = await this.authenticator.authenticate(payload.email, payload.password); + if (!result.success) { + return new Failure({ + reason: 'InvalidCredentials', + error: new Error('Invalid credentials'), + }); + } + + const { user, token } = result; + + return new Success({ user, token }); + } catch (error) { + return new Failure({ + reason: 'UnknownError', + error: error as Error, + }); + } + } +} \ No newline at end of file diff --git a/src/domain/use-cases/user/create-user-use-case.ts b/src/domain/use-cases/user/create-user-use-case.ts index 3a503ac..1e86eed 100644 --- a/src/domain/use-cases/user/create-user-use-case.ts +++ b/src/domain/use-cases/user/create-user-use-case.ts @@ -11,24 +11,24 @@ import { User } from '@/domain/models/user'; import type { IUserRepository } from '@/domain/repositories/user-repository.interface'; import type { IEncryptor } from '@/domain/services/security/encryptor.interface'; -export type CreateUserCasePayload = { +export type CreateUserUseCasePayload = { email: string; username: string; password: string; }; -type CreateUserCaseFailureReason = 'EmailAlreadyExists' | 'UsernameAlreadyExists' | 'UnknownError'; -export type CreateUserCaseFailure = { - reason: CreateUserCaseFailureReason; +type CreateUserUseCaseFailureReason = 'EmailAlreadyExists' | 'UsernameAlreadyExists' | 'UnknownError'; +export type CreateUserUseCaseFailure = { + reason: CreateUserUseCaseFailureReason; error: Error; }; -export type CreateUserCaseSuccess = { +export type CreateUserUseCaseSuccess = { user: User; }; @injectable() -export class CreateUserUseCase implements IUseCase { +export class CreateUserUseCase implements IUseCase { constructor( @inject(CORE_DI_TYPES.IDGenerator) private readonly idGenerator: IIDGenerator, @inject(CORE_DI_TYPES.Time) private readonly time: ITime, @@ -36,11 +36,11 @@ export class CreateUserUseCase implements IUseCase({ + return new Failure({ reason: 'EmailAlreadyExists', error: new Error('Email already exists'), }); @@ -48,7 +48,7 @@ export class CreateUserUseCase implements IUseCase({ + return new Failure({ reason: 'UsernameAlreadyExists', error: new Error('Username already exists'), }); @@ -69,7 +69,7 @@ export class CreateUserUseCase implements IUseCase({ + return new Failure({ reason: 'UnknownError', error: error as Error, }); diff --git a/src/infra/auth/authenticator/jwt-authenticator.ts b/src/infra/auth/authenticator/jwt-authenticator.ts new file mode 100644 index 0000000..1aaa804 --- /dev/null +++ b/src/infra/auth/authenticator/jwt-authenticator.ts @@ -0,0 +1,59 @@ +import { inject, injectable } from 'inversify'; +import jwt from 'jsonwebtoken'; + +import { REPOSITORIES_DI_TYPES } from '@/container/repositories/di-types'; +import type { IUserRepository } from '@/domain/repositories/user-repository.interface'; +import type { IAuthenticator, AuthenticateResponse, GetAuthenticatedUserResponse } from '@/domain/services/auth/authenticator.interface'; + +type JwtPayload = { + id: string; +}; + +@injectable() +export class JwtAuthenticator implements IAuthenticator { + constructor( + private readonly config: { + secret: string; + expiresInSeconds: number; + }, + @inject(REPOSITORIES_DI_TYPES.UserRepository) private readonly userRepository: IUserRepository, + ) {} + + async authenticate(email: string, password: string): Promise { + const user = await this.userRepository.findOneByEmailPassword(email, password); + if (!user) { + return { success: false }; + } + + const token = jwt.sign({ id: user.id }, this.config.secret, { + expiresIn: this.config.expiresInSeconds, + }); + + return { success: true, user, token }; + } + + async getAuthenticatedUser(token: string): Promise { + let decoded: JwtPayload; + try { + decoded = jwt.verify(token, this.config.secret) as JwtPayload; + } catch { + return { + success: false, + reason: 'InvalidToken', + }; + } + + const user = await this.userRepository.findOneById(decoded.id); + if (!user) { + return { + success: false, + reason: 'UserNotFound', + }; + } + + return { + success: true, + user, + }; + } +} \ No newline at end of file diff --git a/src/infra/auth/authenticator/passport-authenticator.ts b/src/infra/auth/authenticator/passport-authenticator.ts deleted file mode 100644 index 4d88241..0000000 --- a/src/infra/auth/authenticator/passport-authenticator.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { inject, injectable } from 'inversify'; -import passport from 'passport'; -import { Strategy as LocalStrategy } from 'passport-local'; -import type { NextFunction, Request, Response } from 'express'; - -import { REPOSITORIES_DI_TYPES } from '@/container/repositories/di-types'; -import { RequestUser } from '@/domain/models/request-user'; -import type { User } from '@/domain/models/user'; -import type { IUserRepository } from '@/domain/repositories/user-repository.interface'; -import type { IAuthenticator, AuthenticateResponse } from '@/domain/services/auth/authenticator.interface'; - -@injectable() -export class PassportAuthenticator implements IAuthenticator { - constructor( - @inject(REPOSITORIES_DI_TYPES.UserRepository) private readonly userRepository: IUserRepository, - ) {} - - configure() { - passport.use(this.getLocalStrategy()); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - passport.serializeUser((user: any, done) => { - done(null, user.id); - }); - - passport.deserializeUser(async (req, id, done: (err: unknown, user: RequestUser | null) => void) => { - const user = await this.userRepository.findOneById(id); - - if (!user) { - // if passport tries to deserialize user but id doesn't exist anymore in db, - // it means the user has been deleted, so logout the request - req.logout(() => undefined); - done(null, null); - } else { - done(null, RequestUser.fromUser(user)); - } - }); - } - - session() { - return passport.session(); - } - - isAuthenticated(req: Request): boolean { - return req.isAuthenticated(); - } - - authenticateLocal(req: Request, res: Response, next: NextFunction): Promise { - return new Promise((resolve) => { - passport.authenticate('local', (err?: unknown, user?: User | false | null) => { - if (err) { - return resolve({ err }); - } - - if (!user) { - return resolve({ user }); - } - - req.logIn(user, (loginErr) => { - if (loginErr) { - return resolve({ err: loginErr }); - } - return resolve({ user }); - }); - - return undefined; - })(req, res, next); - }); - } - - async logout(req: Request): Promise { - await new Promise((resolve, reject) => { - req.logout((err) => { - if (err) { - return reject(err); - } - req.session.destroy(() => resolve()); - return undefined; - }); - }); - } - - private getLocalStrategy() { - return new LocalStrategy( - { - usernameField: 'email', - passwordField: 'password', - }, - async (email, password, done: (err: unknown, user?: User) => void) => { - try { - // Search a user whose username or email is the login parameter - const user = await this.userRepository.findOneByEmailPassword(email, password); - - // If the user doesn't exist or the password is wrong, return error as null and user as undefined - // It allows to distinguish technical error and wrong credentials - if (!user) { - return done(null, undefined); - } - - return done(null, user); - } catch (err) { - return done(err); - } - }, - ); - } -} \ No newline at end of file diff --git a/src/infra/auth/session/express-session-manager.ts b/src/infra/auth/session/express-session-manager.ts deleted file mode 100644 index bc97128..0000000 --- a/src/infra/auth/session/express-session-manager.ts +++ /dev/null @@ -1,55 +0,0 @@ -import pgSession from 'connect-pg-simple'; -import session, { type CookieOptions, type SessionOptions } from 'express-session'; -import { injectable } from 'inversify'; - -import type { ISessionManager } from '@/domain/services/auth/session-manager.interface'; - -type SessionStoreConfig = { - host: string; - port: number; - user: string; - password: string; - database: string; -}; - -export type SessionConfig = { - storeConfig?: SessionStoreConfig; - secret: SessionOptions['secret']; - resave: SessionOptions['resave']; - saveUninitialized: SessionOptions['saveUninitialized']; - cookie: { - secure: CookieOptions['secure']; - maxAge: CookieOptions['maxAge']; - httpOnly: CookieOptions['httpOnly']; - sameSite: 'strict' | 'lax' | 'none'; - }; -}; - -@injectable() -export class ExpressSessionManager implements ISessionManager { - constructor(private readonly config: SessionConfig) {} - - configure() { - let sessionStore: pgSession.PGStore | undefined; - if (this.config.storeConfig) { - const postgresSession = pgSession(session); - sessionStore = new postgresSession({ - conObject: this.config.storeConfig, - createTableIfMissing: true, - }); - } - - return session({ - store: sessionStore, - secret: this.config.secret, - resave: this.config.resave, - saveUninitialized: this.config.saveUninitialized, - cookie: { - secure: this.config.cookie.secure, - maxAge: this.config.cookie.maxAge, - httpOnly: this.config.cookie.httpOnly, - sameSite: this.config.cookie.sameSite, - }, - }); - } -} \ No newline at end of file diff --git a/src/tests/e2e/auth/authenticated.test.ts b/src/tests/e2e/auth/authenticated.test.ts index 03e3570..69b8980 100644 --- a/src/tests/e2e/auth/authenticated.test.ts +++ b/src/tests/e2e/auth/authenticated.test.ts @@ -17,9 +17,9 @@ describe('GET auth/authenticated', () => { }); test('Return true when user is authenticated', async () => { - const { agent } = await testEnv.createAuthenticatedAgent(); + const request = await testEnv.authenticatedRequest(); - const res = await agent.get('/auth/authenticated'); + const res = await request.get('/auth/authenticated'); expect(res.statusCode).toEqual(200); expect(res.body).toEqual({ diff --git a/src/tests/e2e/auth/login.test.ts b/src/tests/e2e/auth/login.test.ts index 5708c8f..eca00b9 100644 --- a/src/tests/e2e/auth/login.test.ts +++ b/src/tests/e2e/auth/login.test.ts @@ -30,9 +30,12 @@ describe('POST auth/login', () => { expect(res.statusCode).toEqual(200); expect(res.body).toEqual({ - id: expect.any(String), - username, - email, + user: { + id: expect.any(String), + username, + email, + }, + token: expect.any(String), }); }); }); \ No newline at end of file diff --git a/src/tests/e2e/auth/logout.test.ts b/src/tests/e2e/auth/logout.test.ts deleted file mode 100644 index fc27839..0000000 --- a/src/tests/e2e/auth/logout.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createTestEnvironment } from '@/tests/helpers/test-helpers'; -import type { TestEnvironment } from '@/tests/helpers/test-environment'; - -let testEnv: TestEnvironment; - -beforeAll(async () => { - testEnv = await createTestEnvironment(); -}); - -afterAll(async () => { - await testEnv.close(); -}); - -describe('POST auth/logout', () => { - beforeEach(async () => { - await testEnv.clearDatabase(); - }); - - test('Logout a user', async () => { - const { agent } = await testEnv.createAuthenticatedAgent(); - - const res = await agent.post('/auth/logout'); - - expect(res.statusCode).toEqual(200); - expect(res.body).toEqual({ - success: true, - }); - }); -}); \ No newline at end of file diff --git a/src/tests/helpers/test-environment.ts b/src/tests/helpers/test-environment.ts index b1951af..5008a4f 100644 --- a/src/tests/helpers/test-environment.ts +++ b/src/tests/helpers/test-environment.ts @@ -36,25 +36,20 @@ export class TestEnvironment { } /** - * Create a test agent. - * @returns The created agent. + * Create a test client. + * @returns The created client. */ request() { return request(this.server); } - /** - * Create an authenticated test agent. A test agent allows to maintain session between multiple requests. - * @param testUserOptions - The authenticated user informations. Optional. - * @returns The created agent. - */ - async createAuthenticatedAgent(testUserOptions?: CreateTestUserOptions) { - const userAgent = request.agent(this.server); + async authenticatedRequest(testUserOptions?: CreateTestUserOptions) { const user = await createTestUser(this, testUserOptions); - await userAgent + const agent = request.agent(this.server); + const res = await agent .post('/auth/login') .send({ email: user.email, password: testUserOptions?.password || 'password' }); - return { agent: userAgent, user }; + return agent.set({ Authorization: `Bearer ${res.body.token}` }); } -} \ No newline at end of file +} diff --git a/src/tests/unit/app/middlewares/authenticated-middleware.test.ts b/src/tests/unit/app/middlewares/authenticated-middleware.test.ts index e1709af..a05e4e5 100644 --- a/src/tests/unit/app/middlewares/authenticated-middleware.test.ts +++ b/src/tests/unit/app/middlewares/authenticated-middleware.test.ts @@ -1,34 +1,30 @@ -import { mock } from 'jest-mock-extended'; import type { Request, Response } from 'express'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; import { AuthenticatedMiddleware } from '@/app/middlewares/authenticated-middleware'; import { HttpError } from '@/app/http-error'; describe('AuthenticatedMiddleware', () => { test('Call next if user is authenticated', () => { - const authenticatorMock = mock(); - authenticatorMock.isAuthenticated.mockReturnValue(true); - - const req = {} as Request; + const req = { + user: { + id: '1', + }, + } as Request; const res = {} as Response; const next = jest.fn(); - const middleware = new AuthenticatedMiddleware(authenticatorMock); + const middleware = new AuthenticatedMiddleware(); middleware.handler(req, res, next); expect(next).toHaveBeenCalled(); }); test('Throw an error when user is not authenticated', () => { - const authenticatorMock = mock(); - authenticatorMock.isAuthenticated.mockReturnValue(false); - const req = {} as Request; const res = {} as Response; const next = jest.fn(); - const middleware = new AuthenticatedMiddleware(authenticatorMock); + const middleware = new AuthenticatedMiddleware(); expect(() => middleware.handler(req, res, next)).toThrow(HttpError.forbidden('User must be authenticated')); }); }); diff --git a/src/tests/unit/app/middlewares/current-user-middleware.test.ts b/src/tests/unit/app/middlewares/current-user-middleware.test.ts new file mode 100644 index 0000000..4e8c99e --- /dev/null +++ b/src/tests/unit/app/middlewares/current-user-middleware.test.ts @@ -0,0 +1,119 @@ +import { mock } from 'jest-mock-extended'; +import type { Request, Response } from 'express'; + +import { CurrentUserMiddleware } from '@/app/middlewares/current-user-middleware'; +import type { IUseCase } from '@/core/use-case/use-case.interface'; +import type { GetCurrentUserUseCaseFailure, GetCurrentUserUseCasePayload, GetCurrentUserUseCaseSuccess } from '@/domain/use-cases/auth/get-current-user-use-case'; +import { Failure, Success } from '@/core/result/result'; +import { User } from '@/domain/models/user'; + +describe('CurrentUserMiddleware', () => { + test('Set user in request if token is valid', async () => { + const getCurrentUserUseCase = mock>(); + + const req = { + headers: { + authorization: 'Bearer token', + }, + } as Request; + const res = mock(); + const next = jest.fn(); + + const user = new User({ + id: '1', + username: 'test', + email: 'test@test.com', + hashPassword: 'test', + createdAt: new Date(), + updatedAt: new Date(), + }); + + getCurrentUserUseCase.execute.mockResolvedValue(new Success({ user })); + + const middleware = new CurrentUserMiddleware(getCurrentUserUseCase); + await middleware.handler(req, res, next); + + expect(getCurrentUserUseCase.execute).toHaveBeenCalledWith({ token: 'token' }); + expect(req.user).toEqual(user); + }); + + test('Set user to null in request if token is invalid', async () => { + const getCurrentUserUseCase = mock>(); + + const req = { + headers: { + authorization: 'Bearer invalid-token', + }, + } as Request; + const res = mock(); + const next = jest.fn(); + + getCurrentUserUseCase.execute.mockResolvedValue(new Failure({ + reason: 'InvalidToken', + error: new Error('Failed to get current user: InvalidToken'), + })); + + const middleware = new CurrentUserMiddleware(getCurrentUserUseCase); + await middleware.handler(req, res, next); + + expect(getCurrentUserUseCase.execute).toHaveBeenCalledWith({ token: 'invalid-token' }); + expect(req.user).toBeNull(); + }); + + test('Set user to null in request if user is not found', async () => { + const getCurrentUserUseCase = mock>(); + + const req = { + headers: { + authorization: 'Bearer token', + }, + } as Request; + const res = mock(); + const next = jest.fn(); + + getCurrentUserUseCase.execute.mockResolvedValue(new Failure({ + reason: 'UserNotFound', + error: new Error('Failed to get current user: UserNotFound'), + })); + + const middleware = new CurrentUserMiddleware(getCurrentUserUseCase); + await middleware.handler(req, res, next); + + expect(getCurrentUserUseCase.execute).toHaveBeenCalledWith({ token: 'token' }); + expect(req.user).toBeNull(); + }); + + test("Don't call use case and set user to null if token is not provided", async () => { + const getCurrentUserUseCase = mock>(); + + const req = { + headers: {}, + } as Request; + const res = mock(); + const next = jest.fn(); + + const middleware = new CurrentUserMiddleware(getCurrentUserUseCase); + await middleware.handler(req, res, next); + + expect(getCurrentUserUseCase.execute).not.toHaveBeenCalled(); + expect(req.user).toBeNull(); + }); + + test("Don't call use case and set user to null if token is not Bearer", async () => { + const getCurrentUserUseCase = mock>(); + + const req = { + headers: { + authorization: 'Basic token', + }, + } as Request; + const res = mock(); + const next = jest.fn(); + + const middleware = new CurrentUserMiddleware(getCurrentUserUseCase); + await middleware.handler(req, res, next); + + expect(getCurrentUserUseCase.execute).not.toHaveBeenCalled(); + expect(req.user).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/tests/unit/app/request-handlers/auth/commands/login-request-handler.test.ts b/src/tests/unit/app/request-handlers/auth/commands/login-request-handler.test.ts index de80998..affc9ee 100644 --- a/src/tests/unit/app/request-handlers/auth/commands/login-request-handler.test.ts +++ b/src/tests/unit/app/request-handlers/auth/commands/login-request-handler.test.ts @@ -2,12 +2,13 @@ import { mock } from 'jest-mock-extended'; import type { Request, Response } from 'express'; import { HttpError } from '@/app/http-error'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; import { User } from '@/domain/models/user'; import { LoginRequestHandler } from '@/app/request-handlers/auth/commands/login-request-handler'; +import type { IUseCase } from '@/core/use-case/use-case.interface'; +import { Failure, Success } from '@/core/result/result'; describe('LoginRequestHandler', () => { - test('Send user infos after login', async () => { + test('Send user infos and token after login', async () => { const req = { body: { email: 'test@test.com', @@ -15,28 +16,34 @@ describe('LoginRequestHandler', () => { }, } as Request; const res = mock(); + res.status.mockReturnThis(); const next = jest.fn(); - const authenticator = mock(); - authenticator.authenticateLocal.mockResolvedValue({ - err: null, - user: new User({ - id: '1', - username: 'test_username', - email: 'test@test.com', - createdAt: new Date(), - updatedAt: new Date(), - hashPassword: 'test_hashpassword', - }), - }); + const loginUseCase = mock(); + loginUseCase.execute.mockResolvedValue( + new Success({ + user: new User({ + id: '1', + username: 'test_username', + email: 'test@test.com', + createdAt: new Date(), + updatedAt: new Date(), + hashPassword: 'test_hashpassword', + }), + token: 'test_token', + }) + ); - const handler = new LoginRequestHandler(authenticator); + const handler = new LoginRequestHandler(loginUseCase); await handler.handle(req, res, next); expect(res.send).toHaveBeenCalledWith({ - id: '1', - username: 'test_username', - email: 'test@test.com', + user: { + id: '1', + username: 'test_username', + email: 'test@test.com', + }, + token: 'test_token', }); }); @@ -48,15 +55,18 @@ describe('LoginRequestHandler', () => { }, } as Request; const res = mock(); + res.status.mockReturnThis(); const next = jest.fn(); - const authenticator = mock(); - authenticator.authenticateLocal.mockResolvedValue({ - err: null, - user: null, - }); + const loginUseCase = mock(); + loginUseCase.execute.mockResolvedValue( + new Failure({ + reason: 'InvalidCredentials', + error: new Error('Incorrect credentials'), + }), + ); - const handler = new LoginRequestHandler(authenticator); + const handler = new LoginRequestHandler(loginUseCase); await expect(handler.handle(req, res, next)).rejects.toStrictEqual(HttpError.unauthorized('Incorrect credentials')); }); }); diff --git a/src/tests/unit/app/request-handlers/auth/commands/logout-request-handler.test.ts b/src/tests/unit/app/request-handlers/auth/commands/logout-request-handler.test.ts deleted file mode 100644 index e4605fc..0000000 --- a/src/tests/unit/app/request-handlers/auth/commands/logout-request-handler.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import type { Request, Response } from 'express'; - -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; -import { LogoutRequestHandler } from '@/app/request-handlers/auth/commands/logout-request-handler'; - -describe('LogoutRequestHandler', () => { - test('Call authenticator logout and send success response', async () => { - const req = {} as Request; - const res = mock(); - - const authenticator = mock(); - - const handler = new LogoutRequestHandler(authenticator); - await handler.handle(req, res); - - expect(authenticator.logout).toHaveBeenCalledWith(req); - expect(res.send).toHaveBeenCalledWith({ - success: true, - }); - }); -}); diff --git a/src/tests/unit/app/request-handlers/auth/queries/authenticated-request-handler.test.ts b/src/tests/unit/app/request-handlers/auth/queries/authenticated-request-handler.test.ts index 67b622e..0acf6d7 100644 --- a/src/tests/unit/app/request-handlers/auth/queries/authenticated-request-handler.test.ts +++ b/src/tests/unit/app/request-handlers/auth/queries/authenticated-request-handler.test.ts @@ -2,17 +2,17 @@ import { mock } from 'jest-mock-extended'; import type { Request, Response } from 'express'; import { AuthenticatedRequestHandler } from '@/app/request-handlers/auth/queries/authenticated-request-handler'; -import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; describe('AuthenticatedRequestHandler', () => { test('Send authenticated true', () => { - const req = {} as Request; + const req = { + user: { + id: '1', + }, + } as Request; const res = mock(); - const authenticator = mock(); - authenticator.isAuthenticated.mockReturnValue(true); - - const handler = new AuthenticatedRequestHandler(authenticator); + const handler = new AuthenticatedRequestHandler(); handler.handle(req, res); expect(res.send).toHaveBeenCalledWith({ @@ -24,10 +24,7 @@ describe('AuthenticatedRequestHandler', () => { const req = {} as Request; const res = mock(); - const authenticator = mock(); - authenticator.isAuthenticated.mockReturnValue(false); - - const handler = new AuthenticatedRequestHandler(authenticator); + const handler = new AuthenticatedRequestHandler(); handler.handle(req, res); expect(res.send).toHaveBeenCalledWith({ diff --git a/src/tests/unit/app/request-handlers/users/commands/create-user-request-handler.test.ts b/src/tests/unit/app/request-handlers/users/commands/create-user-request-handler.test.ts index 3f76944..b7870ba 100644 --- a/src/tests/unit/app/request-handlers/users/commands/create-user-request-handler.test.ts +++ b/src/tests/unit/app/request-handlers/users/commands/create-user-request-handler.test.ts @@ -9,7 +9,7 @@ import { HttpError } from '@/app/http-error'; import { CreateUserRequestHandler } from '@/app/request-handlers/users/commands/create-user-request-handler'; describe('CreateUserRequestHandler', () => { - test('Call CreateUserUserCase and send success response with user infos', async () => { + test('Call CreateUserUseCase and send success response with user infos', async () => { const req = { body: { username: 'test_username', diff --git a/src/tests/unit/domain/use-cases/auth/get-current-user-use-case.test.ts b/src/tests/unit/domain/use-cases/auth/get-current-user-use-case.test.ts new file mode 100644 index 0000000..6710e13 --- /dev/null +++ b/src/tests/unit/domain/use-cases/auth/get-current-user-use-case.test.ts @@ -0,0 +1,86 @@ +import { mock } from 'jest-mock-extended'; + +import { User } from '@/domain/models/user'; +import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; +import type { GetCurrentUserUseCasePayload } from '@/domain/use-cases/auth/get-current-user-use-case'; +import { GetCurrentUserUseCase } from '@/domain/use-cases/auth/get-current-user-use-case'; +import { Failure, Success } from '@/core/result/result'; + +describe('GetCurrentUserUseCase', () => { + test('Call authenticator and send success response', async () => { + const authenticator = mock(); + + const user = new User({ + id: 'test_id', + email: 'test@test.com', + username: 'test_username', + hashPassword: 'test_hash_password', + createdAt: new Date('2021-01-01T00:00:00.000Z'), + updatedAt: new Date('2021-01-01T00:00:00.000Z'), + }); + + authenticator.getAuthenticatedUser.mockResolvedValue({ + success: true, + user, + }); + + const useCase = new GetCurrentUserUseCase(authenticator); + + const payload: GetCurrentUserUseCasePayload = { + token: 'test_token', + }; + + const result = await useCase.execute(payload); + + expect(authenticator.getAuthenticatedUser).toHaveBeenCalledWith(payload.token); + expect(result).toStrictEqual(new Success({ + user, + })); + }); + + test('Call authenticator and send failure response if token is invalid', async () => { + const authenticator = mock(); + + authenticator.getAuthenticatedUser.mockResolvedValue({ + success: false, + reason: 'InvalidToken', + }); + + const useCase = new GetCurrentUserUseCase(authenticator); + + const payload: GetCurrentUserUseCasePayload = { + token: 'test_token', + }; + + const result = await useCase.execute(payload); + + expect(authenticator.getAuthenticatedUser).toHaveBeenCalledWith(payload.token); + expect(result).toStrictEqual(new Failure({ + reason: 'InvalidToken', + error: new Error('Failed to get current user: InvalidToken'), + })); + }); + + test('Call authenticator and send failure response if user is not found', async () => { + const authenticator = mock(); + + authenticator.getAuthenticatedUser.mockResolvedValue({ + success: false, + reason: 'UserNotFound', + }); + + const useCase = new GetCurrentUserUseCase(authenticator); + + const payload: GetCurrentUserUseCasePayload = { + token: 'test_token', + }; + + const result = await useCase.execute(payload); + + expect(authenticator.getAuthenticatedUser).toHaveBeenCalledWith(payload.token); + expect(result).toStrictEqual(new Failure({ + reason: 'UserNotFound', + error: new Error('Failed to get current user: UserNotFound'), + })); + }); +}); diff --git a/src/tests/unit/domain/use-cases/auth/login-use-case.test.ts b/src/tests/unit/domain/use-cases/auth/login-use-case.test.ts new file mode 100644 index 0000000..e50343f --- /dev/null +++ b/src/tests/unit/domain/use-cases/auth/login-use-case.test.ts @@ -0,0 +1,66 @@ +import { mock } from 'jest-mock-extended'; + +import { Failure, Success } from '@/core/result/result'; +import { User } from '@/domain/models/user'; +import type { IAuthenticator } from '@/domain/services/auth/authenticator.interface'; +import type { LoginUseCasePayload } from '@/domain/use-cases/auth/login-use-case'; +import { LoginUseCase } from '@/domain/use-cases/auth/login-use-case'; + +describe('LoginUseCase', () => { + test('Call authenticator and send success response', async () => { + const authenticator = mock(); + + const user = new User({ + id: 'test_id', + email: 'test@test.com', + username: 'test_username', + hashPassword: 'test_hash_password', + createdAt: new Date('2021-01-01T00:00:00.000Z'), + updatedAt: new Date('2021-01-01T00:00:00.000Z'), + }); + + authenticator.authenticate.mockResolvedValue({ + success: true, + user, + token: 'test_token', + }); + + const useCase = new LoginUseCase(authenticator); + + const payload: LoginUseCasePayload = { + email: 'test@test.com', + password: 'test_password', + }; + + const result = await useCase.execute(payload); + + expect(authenticator.authenticate).toHaveBeenCalledWith(payload.email, payload.password); + expect(result).toStrictEqual(new Success({ + user, + token: 'test_token', + })); + }); + + test('Call authenticator and send failure response if credentials are invalid', async () => { + const authenticator = mock(); + + authenticator.authenticate.mockResolvedValue({ + success: false, + }); + + const useCase = new LoginUseCase(authenticator); + + const payload: LoginUseCasePayload = { + email: 'test@test.com', + password: 'test_password', + }; + + const result = await useCase.execute(payload); + + expect(authenticator.authenticate).toHaveBeenCalledWith(payload.email, payload.password); + expect(result).toStrictEqual(new Failure({ + reason: 'InvalidCredentials', + error: new Error('Invalid credentials'), + })); + }); +}); \ No newline at end of file diff --git a/src/tests/unit/domain/use-cases/user/create-user-use-case.test.ts b/src/tests/unit/domain/use-cases/user/create-user-use-case.test.ts index 433a239..5c5e13a 100644 --- a/src/tests/unit/domain/use-cases/user/create-user-use-case.test.ts +++ b/src/tests/unit/domain/use-cases/user/create-user-use-case.test.ts @@ -6,7 +6,7 @@ import type { ITime } from '@/core/time/time.interface'; import { User } from '@/domain/models/user'; import type { IUserRepository } from '@/domain/repositories/user-repository.interface'; import type { IEncryptor } from '@/domain/services/security/encryptor.interface'; -import type { CreateUserCasePayload } from '@/domain/use-cases/user/create-user-use-case'; +import type { CreateUserUseCasePayload } from '@/domain/use-cases/user/create-user-use-case'; import { CreateUserUseCase } from '@/domain/use-cases/user/create-user-use-case'; describe('CreateUserUseCase', () => { @@ -34,7 +34,7 @@ describe('CreateUserUseCase', () => { const useCase = new CreateUserUseCase(idGenerator, time, encryptor, userRepository); - const payload: CreateUserCasePayload = { + const payload: CreateUserUseCasePayload = { email: 'test@test.com', username: 'test_username', password: 'test_password', @@ -57,7 +57,7 @@ describe('CreateUserUseCase', () => { const useCase = new CreateUserUseCase(idGenerator, time, encryptor, userRepository); - const payload: CreateUserCasePayload = { + const payload: CreateUserUseCasePayload = { email: 'test@test.com', username: 'test_username', password: 'test_password', @@ -81,7 +81,7 @@ describe('CreateUserUseCase', () => { const useCase = new CreateUserUseCase(idGenerator, time, encryptor, userRepository); - const payload: CreateUserCasePayload = { + const payload: CreateUserUseCasePayload = { email: 'test@test.com', username: 'test_username', password: 'test_password', diff --git a/yarn.lock b/yarn.lock index 1043a01..c998e0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -834,7 +834,7 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express-session@*", "@types/express-session@1.18.1": +"@types/express-session@*": version "1.18.1" resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.18.1.tgz#67c629a34b60a63a4724f359aac0c0e6d1f15365" integrity sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg== @@ -900,6 +900,14 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonwebtoken@^9.0.9": + version "9.0.9" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz#a4c3a446c0ebaaf467a58398382616f416345fb3" + integrity sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ== + dependencies: + "@types/ms" "*" + "@types/node" "*" + "@types/methods@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" @@ -917,6 +925,11 @@ dependencies: "@types/node" "*" +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node@*": version "22.10.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" @@ -931,30 +944,6 @@ dependencies: undici-types "~6.20.0" -"@types/passport-local@1.0.38": - version "1.0.38" - resolved "https://registry.yarnpkg.com/@types/passport-local/-/passport-local-1.0.38.tgz#8073758188645dde3515808999b1c218a6fe7141" - integrity sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg== - dependencies: - "@types/express" "*" - "@types/passport" "*" - "@types/passport-strategy" "*" - -"@types/passport-strategy@*": - version "0.2.38" - resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3" - integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA== - dependencies: - "@types/express" "*" - "@types/passport" "*" - -"@types/passport@*", "@types/passport@1.0.17": - version "1.0.17" - resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6" - integrity sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg== - dependencies: - "@types/express" "*" - "@types/pg@*": version "8.11.10" resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.11.10.tgz#b8fb2b2b759d452fe3ec182beadd382563b63291" @@ -1462,6 +1451,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -1638,13 +1632,6 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -connect-pg-simple@10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz#972b08d9fc6a1861c523a6c9166240a24b4bc3ca" - integrity sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A== - dependencies: - pg "^8.12.0" - content-disposition@0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -1667,21 +1654,11 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie-signature@1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" - integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== - cookie@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== -cookie@0.7.2: - version "0.7.2" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== - cookiejar@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" @@ -1879,6 +1856,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2240,20 +2224,6 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" -express-session@1.18.1: - version "1.18.1" - resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.18.1.tgz#88d0bbd41878882840f24ec6227493fcb167e8d5" - integrity sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA== - dependencies: - cookie "0.7.2" - cookie-signature "1.0.7" - debug "2.6.9" - depd "~2.0.0" - on-headers "~1.0.2" - parseurl "~1.3.3" - safe-buffer "5.2.1" - uid-safe "~2.1.5" - express@4.21.2: version "4.21.2" resolved "https://registry.yarnpkg.com/express/-/express-4.21.2.tgz#cf250e48362174ead6cea4a566abef0162c1ec32" @@ -3491,6 +3461,39 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonwebtoken@^9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + 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" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -3535,6 +3538,36 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -3545,6 +3578,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -3952,27 +3990,6 @@ parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -passport-local@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" - integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow== - dependencies: - passport-strategy "1.x.x" - -passport-strategy@1.x.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" - integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== - -passport@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/passport/-/passport-0.7.0.tgz#3688415a59a48cf8068417a8a8092d4492ca3a05" - integrity sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ== - dependencies: - passport-strategy "1.x.x" - pause "0.0.1" - utils-merge "^1.0.1" - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -4011,11 +4028,6 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -pause@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" - integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== - pg-cloudflare@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" @@ -4070,7 +4082,7 @@ pg-types@^4.0.1: postgres-interval "^3.0.0" postgres-range "^1.1.1" -pg@8.13.1, pg@^8.12.0: +pg@8.13.1: version "8.13.1" resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== @@ -4247,11 +4259,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -random-bytes@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ== - range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -4989,13 +4996,6 @@ typescript@5.7.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== -uid-safe@~2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" - integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== - dependencies: - random-bytes "~1.0.0" - unbox-primitive@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" @@ -5036,7 +5036,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -utils-merge@1.0.1, utils-merge@^1.0.1: +utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==