├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github └── dependabot.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── __test__ ├── middleware │ ├── isAuth.test.ts │ └── xssMiddleware.test.ts └── utils │ ├── compressFilter.test.ts │ └── sanitize.test.ts ├── assets └── refresh_token_rotation_flow_diagram.png ├── docker-compose.yml ├── jest.config.js ├── package.json ├── prisma ├── migrations │ ├── 20230307220106_init │ │ └── migration.sql │ ├── 20230308154922_user_password │ │ └── migration.sql │ ├── 20230309120100_refresh_token │ │ └── migration.sql │ ├── 20230309155008_removed_expires │ │ └── migration.sql │ ├── 20230317200854_resest_token │ │ └── migration.sql │ ├── 20230317205303_email_verification_token │ │ └── migration.sql │ ├── 20230318163942_refresh_token_changes │ │ └── migration.sql │ ├── 20230318164340_refresh_token_changes_back │ │ └── migration.sql │ ├── 20230330094356_changed_name │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── app.ts ├── config │ ├── config.ts │ ├── cookieConfig.ts │ ├── cors.ts │ ├── nodemailer.ts │ └── prisma.ts ├── controller │ ├── auth.controller.ts │ ├── forgotPassword.controller.ts │ └── verifyEmail.controller.ts ├── index.ts ├── middleware │ ├── authLimiter.ts │ ├── errorHandler.ts │ ├── isAuth.ts │ ├── logger.ts │ ├── validate.ts │ └── xssMiddleware.ts ├── routes │ └── v1 │ │ ├── auth.route.ts │ │ ├── index.ts │ │ ├── password.route.ts │ │ └── verifyEmail.route.ts ├── types │ ├── env.d.ts │ ├── express.d.ts │ ├── jwt.d.ts │ └── types.ts ├── utils │ ├── compressFilter.util.ts │ ├── generateTokens.util.ts │ ├── sanitize.util.ts │ └── sendEmail.util.ts └── validations │ ├── auth.validation.ts │ ├── password.validation.ts │ └── verifyEmail.validation.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.test.json ├── yarn-error.log └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | coverage 4 | .env -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # App's running environment 2 | NODE_ENV= 3 | 4 | # App's running port 5 | PORT= 6 | 7 | # Server url 8 | SERVER_URL= 9 | 10 | # Cors origin url 11 | CORS_ORIGIN= 12 | 13 | # Run node -e "console.log(require('crypto').randomBytes(256).toString('base64'));" in your console to generate a secret 14 | ACCESS_TOKEN_SECRET= 15 | 16 | REFRESH_TOKEN_SECRET= 17 | 18 | ACCESS_TOKEN_EXPIRE= 19 | 20 | REFRESH_TOKEN_EXPIRE= 21 | 22 | # name of the refresh token cookie 23 | REFRESH_TOKEN_COOKIE_NAME= 24 | 25 | MYSQL_DATABASE= 26 | MYSQL_ROOT_PASSWORD= 27 | 28 | # Example: mysql://USER:PASSWORD@HOST:PORT/DATABASE 29 | DATABASE_URL= 30 | 31 | # Configuration for the emial service 32 | SMTP_HOST= 33 | SMTP_PORT= 34 | SMTP_USERNAME= 35 | SMTP_PASSWORD= 36 | EMAIL_FROM= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn.lock 4 | yarn-error.log 5 | logs 6 | coverage 7 | data 8 | *.config.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2022": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | "plugin:security/recommended", 12 | "standard-with-typescript", 13 | "plugin:node/recommended", 14 | "plugin:security/recommended" 15 | ], 16 | "overrides": [], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 2022, 20 | "sourceType": "module", 21 | "project": "./tsconfig.json" 22 | }, 23 | "plugins": ["@typescript-eslint", "promise", "import"], 24 | "rules": { 25 | "indent": "off", 26 | "linebreak-style": ["error", "unix"], 27 | "quotes": ["error", "single"], 28 | "semi": "off", 29 | 30 | "@typescript-eslint/indent": "off", 31 | "@typescript-eslint/semi": "off", 32 | "@typescript-eslint/member-delimiter-style": "off", 33 | "@typescript-eslint/strict-boolean-expressions": "off", 34 | "@typescript-eslint/explicit-function-return-type": "off", 35 | "@typescript-eslint/restrict-plus-operands": "off", 36 | "@typescript-eslint/restrict-template-expressions": "off", 37 | "@typescript-eslint/prefer-optional-chain": "off", 38 | "@typescript-eslint/no-misused-promises": "off", 39 | "@typescript-eslint/consistent-type-assertions": "off", 40 | 41 | // plugin: node/recommended 42 | "node/exports-style": ["error", "module.exports"], 43 | "node/file-extension-in-import": "off", 44 | "node/no-missing-import": "off", 45 | "node/prefer-global/buffer": ["error", "always"], 46 | "node/prefer-global/console": ["error", "always"], 47 | "node/prefer-global/process": ["error", "always"], 48 | "node/prefer-global/url-search-params": ["error", "always"], 49 | "node/prefer-global/url": ["error", "always"], 50 | "node/prefer-promises/dns": "error", 51 | "node/prefer-promises/fs": "error", 52 | "node/no-unsupported-features/es-syntax": "off", 53 | "node/no-extraneous-import": "off", 54 | "node/no-unsupported-features/es-builtins": "off", 55 | 56 | // plugin promise 57 | "promise/always-return": "error", 58 | "promise/no-return-wrap": "error", 59 | "promise/param-names": "error", 60 | "promise/catch-or-return": "error", 61 | "promise/no-native": "off", 62 | "promise/no-nesting": "warn", 63 | "promise/no-promise-in-callback": "off", 64 | "promise/no-callback-in-promise": "warn", 65 | "promise/avoid-new": "warn", 66 | "promise/no-new-statics": "error", 67 | "promise/no-return-in-finally": "warn", 68 | "promise/valid-params": "warn", 69 | 70 | "sort-imports": [ 71 | "error", 72 | { "ignoreDeclarationSort": true, "ignoreCase": true } 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "yarn" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ 3 | dist/ 4 | yarn.lock 5 | yarn-error.log 6 | logs/ 7 | coverage/ 8 | data -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn.lock 4 | yarn-error.log 5 | logs 6 | coverage 7 | data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | louiskaan.ay@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to express-ts-auth-service 3 | 4 | First off, thanks for taking the time to contribute! ❤️ 5 | 6 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 7 | 8 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 9 | > - Star the project 10 | > - Tweet about it 11 | > - Refer this project in your project's readme 12 | > - Mention the project at local meetups and tell your friends/colleagues 13 | 14 | 15 | ## Table of Contents 16 | 17 | - [Code of Conduct](#code-of-conduct) 18 | - [I Have a Question](#i-have-a-question) 19 | - [I Want To Contribute](#i-want-to-contribute) 20 | - [Reporting Bugs](#reporting-bugs) 21 | - [Suggesting Enhancements](#suggesting-enhancements) 22 | - [Your First Code Contribution](#your-first-code-contribution) 23 | - [Improving The Documentation](#improving-the-documentation) 24 | - [Styleguides](#styleguides) 25 | - [Commit Messages](#commit-messages) 26 | - [Join The Project Team](#join-the-project-team) 27 | 28 | 29 | ## Code of Conduct 30 | 31 | This project and everyone participating in it is governed by the 32 | [express-ts-auth-service Code of Conduct](https://github.com/Louis3797/express-ts-auth-service/blob/main/CODE_OF_CONDUCT.md). 33 | By participating, you are expected to uphold this code. Please report unacceptable behavior 34 | to <>. 35 | 36 | 37 | ## I Have a Question 38 | 39 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/Louis3797/express-ts-auth-service#readme). 40 | 41 | Before you ask a question, it is best to search for existing [Issues](https://github.com/Louis3797/express-ts-auth-service/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 42 | 43 | If you then still feel the need to ask a question and need clarification, we recommend the following: 44 | 45 | - Open an [Issue](https://github.com/Louis3797/express-ts-auth-service/issues/new). 46 | - Provide as much context as you can about what you're running into. 47 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 48 | 49 | We will then take care of the issue as soon as possible. 50 | 51 | ## I Want To Contribute 52 | 53 | > ### Legal Notice 54 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 55 | 56 | ### Reporting Bugs 57 | 58 | 59 | #### Before Submitting a Bug Report 60 | 61 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 62 | 63 | - Make sure that you are using the latest version. 64 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/Louis3797/express-ts-auth-service#readme). If you are looking for support, you might want to check [this section](#i-have-a-question)). 65 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/Louis3797/express-ts-auth-service/issues?q=label%3Abug). 66 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 67 | - Collect information about the bug: 68 | - Stack trace (Traceback) 69 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 70 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 71 | - Possibly your input and the output 72 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 73 | 74 | 75 | 76 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 77 | 78 | - Open an [Issue](https://github.com/Louis3797/express-ts-auth-service/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 79 | - Explain the behavior you would expect and the actual behavior. 80 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 81 | - Provide the information you collected in the previous section. 82 | 83 | Once it's filed: 84 | 85 | - The project team will label the issue accordingly. 86 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 87 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 88 | 89 | 90 | 91 | 92 | ### Suggesting Enhancements 93 | 94 | This section guides you through submitting an enhancement suggestion for express-ts-auth-service, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 95 | 96 | 97 | #### Before Submitting an Enhancement 98 | 99 | - Make sure that you are using the latest version. 100 | - Read the [documentation](https://github.com/Louis3797/express-ts-auth-service#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. 101 | - Perform a [search](https://github.com/Louis3797/express-ts-auth-service/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 102 | - 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. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 103 | 104 | 105 | #### How Do I Submit a Good Enhancement Suggestion? 106 | 107 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/Louis3797/express-ts-auth-service/issues). 108 | 109 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 110 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 111 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 112 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 113 | - **Explain why this enhancement would be useful** to most express-ts-auth-service users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 114 | 115 | 116 | 117 | ### Your First Code Contribution 118 | 122 | 123 | ### Improving The Documentation 124 | 128 | 129 | ## Styleguides 130 | ### Commit Messages 131 | 134 | 135 | ## Join The Project Team 136 | 137 | 138 | 139 | ## Attribution 140 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! 141 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:18-alpine3.16 AS build 3 | 4 | # Set the working directory to /app 5 | WORKDIR /app 6 | 7 | # Copy package.json and yarn.lock to the container 8 | COPY package.json yarn.lock ./ 9 | 10 | # Install development dependencies 11 | RUN apk add --no-cache --virtual .build-deps \ 12 | gcc \ 13 | g++ \ 14 | python3 \ 15 | && yarn install --frozen-lockfile --production=false \ 16 | && apk del .build-deps \ 17 | && yarn cache clean 18 | 19 | # Copy the rest of the application code to the container 20 | COPY . . 21 | 22 | # Build the application 23 | RUN yarn build 24 | 25 | # Run stage 26 | FROM node:18-alpine3.16 AS run 27 | 28 | # Set the working directory to /app 29 | WORKDIR /app 30 | 31 | # Copy package.json and yarn.lock to the container 32 | COPY package.json yarn.lock ./ 33 | 34 | # Install dumb-init 35 | RUN apk add --no-cache dumb-init 36 | 37 | # Add a non-root user to run the application 38 | RUN addgroup -g 1001 -S nodejs \ 39 | && adduser -S nodejs -u 1001 \ 40 | && chown -R nodejs:nodejs /app 41 | 42 | # Switch to the non-root user 43 | USER nodejs 44 | 45 | # Install production dependencies 46 | RUN yarn install --frozen-lockfile --production=true && yarn cache clean 47 | 48 | # Copy the built application code from the build stage 49 | COPY --chown=nodejs:nodejs --from=build /app/dist ./dist 50 | 51 | # Copy the prisma directory from the build stage 52 | COPY --chown=nodejs:nodejs --from=build /app/prisma ./prisma 53 | 54 | ENV NODE_ENV=production 55 | ENV PORT=3000 56 | 57 | # Generate prisma client 58 | RUN yarn prisma generate 59 | 60 | # Expose port for the application to listen on 61 | EXPOSE 4040 62 | 63 | # Start the application with dumb-init 64 | CMD ["dumb-init", "node", "dist/index.js"] 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Louis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 9 |
10 | 11 |

Express-Ts-Auth-Service

12 | 13 |

14 | A pre-built authentication server that uses JSON Web Tokens (JWT) for authentication. It is built using Express.js, TypeScript and MySQL 15 |

16 | 17 | 18 |

19 | 20 | contributors 21 | 22 | 23 | last update 24 | 25 | 26 | forks 27 | 28 | 29 | stars 30 | 31 | 32 | open issues 33 | 34 | 35 | license 36 | 37 |

38 | 39 |

40 | Documentation 41 | · 42 | Report Bug 43 | · 44 | Request Feature 45 |

46 |
47 | 48 | 49 | 50 | # Table of Contents 51 | 52 | - [Table of Contents](#table-of-contents) 53 | - [About the Project](#about-the-project) 54 | - [Tech Stack](#tech-stack) 55 | - [Features](#features) 56 | - [Endpoints](#endpoints) 57 | - [Project Structure](#project-structure) 58 | - [Database](#database) 59 | - [Account](#account) 60 | - [User](#user) 61 | - [RefreshToken](#refreshtoken) 62 | - [ResetToken](#resettoken) 63 | - [EmailVerificationToken](#emailverificationtoken) 64 | - [Refresh Token Rotation](#refresh-token-rotation) 65 | - [Environment Variables](#environment-variables) 66 | - [Getting Started](#getting-started) 67 | - [Prerequisites](#prerequisites) 68 | - [Installation](#installation) 69 | - [Linting](#linting) 70 | - [Running Tests](#running-tests) 71 | - [Run Locally](#run-locally) 72 | - [Run with Docker](#run-with-docker) 73 | - [Roadmap](#roadmap) 74 | - [Contributing](#contributing) 75 | - [Code of Conduct](#code-of-conduct) 76 | - [License](#license) 77 | - [Contact](#contact) 78 | - [Acknowledgements](#acknowledgements) 79 | 80 | 81 | 82 | ## About the Project 83 | 84 | This pre-built authentication server is designed to simplify the process of adding secure user authentication to your web or mobile application. It provides a ready-made solution that uses JSON Web Tokens (JWT) to ensure reliable and secure user sessions, saving you time and resources that would otherwise be required to develop an authentication system from scratch. Built using Express.js and TypeScript, this server is also highly customizable and can be extended to meet the specific needs of your application. By integrating our authentication server into your application, you can rest assured that your users' data and sessions are well protected, leaving you free to focus on other important aspects of your application. 85 | 86 | 87 | 88 | ### Tech Stack 89 | 90 |

91 | 92 | 93 | 94 |

95 | 96 | 97 | 98 | ### Features 99 | 100 | - :black_nib: Written in TypeScript for type-safe code 101 | - :floppy_disk: Utilize a MySQL database to efficiently store user data 102 | - :speaking_head: Interacts with the database using the powerful Prisma ORM 103 | - :lock: Implements secure authentication measures with JWTs, ensuring secure access to sensitive data 104 | - :key: Implements robust password hashing using Argon2 for maximum security 105 | - :recycle: Incorporates refresh token rotation functionality to enhance the security 106 | - :white_check_mark: Includes email verification functionality for new user sign-ups 107 | - :new: Provides a reset password function for users who have forgotten their password 108 | - :rabbit2: Enables faster data transfer by implementing GZIP compression 109 | - :policeman: Implements essential security features using Helmet middleware 110 | - :cookie: Parses cookies seamlessly with cookie-parser middleware 111 | - :gear: Allows cross-origin resource sharing using CORS 112 | - :soap: Sanitizes request data against cross-site-scripting with xss middleware 113 | - :capital_abcd: Manages environment variables with ease using dotenv 114 | - :male_detective: Enforces high code quality standards with ESLint and Prettier 115 | - :horse_racing: Implements rate limiting to prevent abuse and improve server performance 116 | - :information_source: Accurately manages HTTP response status codes using http-status library 117 | - :warning: Validates user input with the powerful and flexible Joi library 118 | - :email: Facilitates sending of emails using nodemailer library 119 | - :memo: Enables detailed logging of server activities using winston library 120 | - :dog: Implements Git hooks with Husky to optimize development processes 121 | - :test_tube: Ensure reliability and robustness of the application with thorough testing using Jest and Supertest 122 | 123 | 124 | 125 | ### Endpoints 126 | 127 | ``` 128 | POST /v1/auth/signup - Signup 129 | POST /v1/auth/login - Login 130 | POST /v1/auth/refresh - Refresh access token 131 | POST /v1/forgot-password - Send reset password email 132 | POST /v1/reset-password/:token - Reset password 133 | POST /v1/send-verification-email - Send verification email 134 | POST /v1/verify-email/:token - Verify email 135 | ``` 136 | 137 | 138 | 139 | ### Project Structure 140 | 141 | ``` 142 | ./src 143 | ├── config/ # Config files 144 | ├── controller/ # Route controllers 145 | ├── middleware/ # Custom middlewares 146 | ├── routes/ # Routes 147 | ├── types/ # Types 148 | ├── utils/ # Utility classes and functions 149 | ├── validations/ # Validation schemas 150 | ├── app.ts # Express App 151 | └── index.ts # App Entrypoint 152 | ``` 153 | 154 | 155 | 156 | ### Database 157 | 158 | Our server relies on MySQL as its primary database management system to store and manage all relevant data. MySQL is a popular and widely used open-source relational database system that provides efficient, secure, and scalable storage and retrieval of data. 159 | 160 | To simplify and streamline the process of managing the data stored in the MySQL database, we utilize Prisma, which is a modern, type-safe ORM that supports various databases, including MySQL. 161 | 162 | Prisma helps us to write database queries in a more readable and intuitive way, making it easier to manage the data stored in our MySQL database. By using Prisma as our ORM of choice, we can also ensure that our application remains scalable, efficient, and maintainable. 163 | 164 | If you're interested in the structure of our database, you can take a look at the data model presented below, which provides an overview of the tables, columns, and relationships within the database. 165 | 166 | ```js 167 | 168 | model Account { 169 | id String @id @default(cuid()) 170 | userId String 171 | type String 172 | provider String 173 | providerAccountId String 174 | refresh_token String? @db.Text 175 | access_token String? @db.Text 176 | expiresAt DateTime 177 | token_type String? 178 | scope String? 179 | id_token String? @db.Text 180 | session_state String? 181 | 182 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 183 | 184 | @@unique([provider, providerAccountId]) 185 | } 186 | 187 | model User { 188 | id String @id @default(cuid()) 189 | name String 190 | email String? @unique 191 | password String 192 | emailVerified DateTime? 193 | createdAt DateTime @default(now()) 194 | accounts Account[] 195 | refreshTokens RefreshToken[] 196 | resetToken ResetToken[] 197 | emailVerificationToken EmailVerificationToken[] 198 | } 199 | 200 | model RefreshToken { 201 | id String @id @default(cuid()) 202 | token String @unique 203 | user User @relation(fields: [userId], references: [id]) 204 | userId String 205 | createdAt DateTime @default(now()) 206 | } 207 | 208 | model ResetToken { 209 | id String @id @default(cuid()) 210 | token String @unique 211 | expiresAt DateTime 212 | user User @relation(fields: [userId], references: [id]) 213 | userId String 214 | createdAt DateTime @default(now()) 215 | } 216 | 217 | model EmailVerificationToken { 218 | id String @id @default(cuid()) 219 | token String @unique 220 | expiresAt DateTime 221 | user User @relation(fields: [userId], references: [id]) 222 | userId String 223 | createdAt DateTime @default(now()) 224 | } 225 | ``` 226 | 227 | #### Account 228 | 229 | > Social auth is not yet implemented so that the entity can be different in the future 230 | 231 | The Account entity represents a linked social media account for a user. It has the following fields: 232 | 233 | - id: A unique identifier for the account. 234 | - userId: The ID of the user associated with the account. 235 | - type: The type of account, e.g. oauth. 236 | - provider: The provider of the account, e.g. facebook. 237 | - providerAccountId: The ID associated with the account from the provider's perspective. 238 | - refresh_token: A refresh token used to obtain a new access token. 239 | - access_token: An access token used to authenticate requests to the provider's API. 240 | - expiresAt: The expiration time of the access token. 241 | - token_type: The type of access token. 242 | - scope: The scope of the access token. 243 | - id_token: An ID token associated with the account. 244 | - session_state: The session state of the account. 245 | 246 | #### User 247 | 248 | The User entity represents a user of the application. It has the following fields: 249 | 250 | - id: A unique identifier for the user. 251 | - name: The name of the user. 252 | - email: The email address of the user. 253 | - password: The password of the user. 254 | - emailVerified: The date and time when the user's email address was verified. 255 | - createdAt: The date of creation. 256 | - accounts: A list of linked social media accounts for the user. 257 | - refreshTokens: A list of refresh tokens associated with the user. 258 | - resetToken: A list of reset tokens associated with the user. 259 | - emailVerificationToken: A list of email verification tokens associated with the user. 260 | 261 | #### RefreshToken 262 | 263 | The RefreshToken entity represents a refresh token used to obtain a new access token. It has the following fields: 264 | 265 | - id: A unique identifier for the refresh token. 266 | - token: The token itself. 267 | - user: The user associated with the refresh token. 268 | - userId: The ID of the user associated with the refresh token. 269 | - createdAt: The date of creation. 270 | 271 | #### ResetToken 272 | 273 | The ResetToken entity represents a reset token used to reset a user's password. It has the following fields: 274 | 275 | - id: A unique identifier for the refresh token. 276 | - token: The token itself. 277 | - expiresAt: The expiration time of the reset token. 278 | - user: The user associated with the reset token. 279 | - userId: The ID of the user associated with the reset token. 280 | - createdAt: The date of creation. 281 | 282 | #### EmailVerificationToken 283 | 284 | The EmailVerificationToken entity represents a token used to verify a user's email address. It has the following fields: 285 | 286 | - id: A unique identifier for the refresh token. 287 | - token: The token itself. 288 | - expiresAt: The expiration time of the email verification token. 289 | - user: The user associated with the email verification token. 290 | - userId: The ID of the user associated with the email verification token. 291 | - createdAt: The date of creation. 292 | 293 | 294 | 295 | ### Refresh Token Rotation 296 | 297 | Refresh token rotation is a security practice used to mitigate the risk of unauthorized access to a user's account or resources. When a user logs in to an application, the application issues an access token and a refresh token. The access token is used to access the user's resources, while the refresh token is used to obtain a new access token when the current one expires. 298 | 299 | In refresh token rotation, the application periodically rotates the refresh token, meaning it invalidates the old refresh token and issues a new one. This practice can limit the amount of time an attacker can use a stolen refresh token to gain access to the user's account or resources. By rotating the refresh token, the application reduces the risk of a long-lived refresh token being used to access the user's account or resources without their permission. 300 | 301 | ![Refresh Token Rotation Flow](https://github.com/Louis3797/express-ts-auth-service/blob/main/assets/refresh_token_rotation_flow_diagram.png) 302 | 303 | 304 | 305 | ### Environment Variables 306 | 307 | To run this project, you will need to add the following environment variables to your .env file 308 | 309 | ``` 310 | # App's running environment 311 | NODE_ENV= 312 | 313 | # App's running port 314 | PORT= 315 | 316 | # Server url 317 | SERVER_URL= 318 | 319 | # Cors origin url 320 | CORS_ORIGIN= 321 | 322 | # Run node -e "console.log(require('crypto').randomBytes(256).toString('base64'));" in your console to generate a secret 323 | ACCESS_TOKEN_SECRET= 324 | 325 | REFRESH_TOKEN_SECRET= 326 | 327 | ACCESS_TOKEN_EXPIRE= 328 | 329 | REFRESH_TOKEN_EXPIRE= 330 | 331 | # name of the refresh token cookie 332 | REFRESH_TOKEN_COOKIE_NAME= 333 | 334 | MYSQL_DATABASE= 335 | MYSQL_ROOT_PASSWORD= 336 | 337 | # Example: mysql://USER:PASSWORD@HOST:PORT/DATABASE 338 | DATABASE_URL= 339 | 340 | # Configuration for the emial service 341 | SMTP_HOST= 342 | SMTP_PORT= 343 | SMTP_USERNAME= 344 | SMTP_PASSWORD= 345 | EMAIL_FROM= 346 | ``` 347 | 348 | See .env.example for further details 349 | 350 | 351 | 352 | ## Getting Started 353 | 354 | 355 | 356 | ### Prerequisites 357 | 358 | This project uses Yarn as package manager 359 | 360 | ```bash 361 | npm install --global yarn 362 | ``` 363 | 364 | 365 | 366 | ### Installation 367 | 368 | ```bash 369 | git clone https://github.com/Louis3797/express-ts-auth-service.git 370 | ``` 371 | 372 | Go to the project directory 373 | 374 | ```bash 375 | cd express-ts-auth-service 376 | ``` 377 | 378 | ```bash 379 | yarn install 380 | ``` 381 | 382 | ### Linting 383 | 384 | ```bash 385 | # run ESLint 386 | yarn lint 387 | 388 | # fix ESLint errors 389 | yarn lint:fix 390 | 391 | # run prettier 392 | yarn prettier:check 393 | 394 | # fix prettier errors 395 | yarn prettier:format 396 | 397 | # fix prettier errors in specific file 398 | yarn prettier:format:file 399 | ``` 400 | 401 | 402 | 403 | ### Running Tests 404 | 405 | To run tests, run the following command 406 | 407 | ```bash 408 | yarn test 409 | ``` 410 | 411 | Run tests with watch flag 412 | 413 | ```bash 414 | yarn test:watch 415 | ``` 416 | 417 | See test coverage 418 | 419 | ```bash 420 | yarn coverage 421 | ``` 422 | 423 | 424 | 425 | ### Run Locally 426 | 427 | Start the server in development mode 428 | 429 | > Note: Dont forget to define the .env variables 430 | 431 | ```bash 432 | yarn dev 433 | ``` 434 | 435 | Start the server in production mode 436 | 437 | ```bash 438 | yarn start 439 | ``` 440 | 441 | 442 | 443 | ### Run with Docker 444 | 445 | Run docker compose 446 | 447 | ```bash 448 | cd express-ts-auth-service 449 | docker-compose up 450 | ``` 451 | 452 | 453 | ## Roadmap 454 | 455 | - [ ] Winston + morgan for logging ? 456 | - [ ] Clean and order imports 457 | - [x] Order imports 458 | - [ ] Add index.ts files for cleaner imports 459 | - [x] Add xss attack prevention middleware 460 | - [ ] Add API Endpoint documentation 461 | - [ ] Social Auth 462 | - [ ] Google 463 | - [ ] Github 464 | - [ ] Facebook 465 | - [ ] Twitter 466 | - [ ] Better Error handeling 467 | - [ ] Custom Error classes like ```AccessTokenNotFoundError``` 468 | - [ ] Integration Tests 469 | 470 | 471 | ## Contributing 472 | 473 | 474 | 475 | 476 | 477 | Contributions are always welcome! 478 | 479 | See `CONTRIBUTING.md` for ways to get started. 480 | 481 | 482 | ### Code of Conduct 483 | 484 | Please read the [Code of Conduct](https://github.com/Louis3797/express-ts-auth-service/blob/main/CODE_OF_CONDUCT.md) 485 | 486 | 487 | 488 | ## License 489 | 490 | Distributed under the MIT License. See LICENSE for more information. 491 | 492 | 493 | 494 | ## Contact 495 | 496 | Louis-Kaan Ay - louiskaan.ay@gmail.com 497 | 498 | Project Link: [https://github.com/Louis3797/express-ts-auth-service](https://github.com/Louis3797/express-ts-auth-service) 499 | 500 | 501 | 502 | ## Acknowledgements 503 | 504 | - [Readme Template](https://github.com/Louis3797/awesome-readme-template) 505 | - [Node Express Boilerplate](https://github.com/hagopj13/node-express-boilerplate) 506 | - [Express Ts Boilerplate](https://github.com/Louis3797/express-ts-boilerplate) 507 | -------------------------------------------------------------------------------- /__test__/middleware/isAuth.test.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import type { NextFunction, Request, Response } from 'express'; 3 | import isAuth from '../../src/middleware/isAuth'; 4 | import jwt, { type JwtPayload } from 'jsonwebtoken'; 5 | import config from '../../src/config/config'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-expect-error 9 | const { sign } = jwt; 10 | 11 | describe('isAuth middleware', () => { 12 | let req: Request; 13 | let res: Response; 14 | let next: NextFunction; 15 | 16 | beforeEach(() => { 17 | req = {} as Request; 18 | res = { 19 | sendStatus: jest.fn() 20 | } as unknown as Response; 21 | next = jest.fn(); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.resetAllMocks(); 26 | }); 27 | 28 | it('should return 401 if no authorization header is present', () => { 29 | isAuth(req, res, next); 30 | 31 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.UNAUTHORIZED); 32 | expect(next).not.toHaveBeenCalled(); 33 | }); 34 | 35 | it('should return 401 if authorization header does not start with "Bearer "', () => { 36 | req.headers = { authorization: 'InvalidToken' }; 37 | 38 | isAuth(req, res, next); 39 | 40 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.UNAUTHORIZED); 41 | expect(next).not.toHaveBeenCalled(); 42 | }); 43 | 44 | it('should return 401 if token is undefined', () => { 45 | req.headers = { authorization: 'Bearer ' }; 46 | 47 | isAuth(req, res, next); 48 | 49 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.UNAUTHORIZED); 50 | expect(next).not.toHaveBeenCalled(); 51 | }); 52 | 53 | it('should return 403 if token is invalid', () => { 54 | req.headers = { authorization: 'Bearer InvalidToken' }; 55 | 56 | isAuth(req, res, next); 57 | 58 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.FORBIDDEN); 59 | expect(next).not.toHaveBeenCalled(); 60 | }); 61 | 62 | it('should call next() if token is valid', () => { 63 | const payload: JwtPayload = { userId: '123' }; 64 | const token = sign(payload, config.jwt.access_token.secret); 65 | 66 | req.headers = { authorization: `Bearer ${token}` }; 67 | 68 | isAuth(req, res, next); 69 | 70 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error 71 | // @ts-ignore 72 | expect(req.payload).toBeDefined(); 73 | expect(next).toHaveBeenCalled(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /__test__/middleware/xssMiddleware.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import type { NextFunction, Request, Response } from 'express'; 3 | import { xssMiddleware } from '../../src/middleware/xssMiddleware'; 4 | import type { SanitizeOptions } from '../../src/types/types'; 5 | 6 | describe('xssMiddleware', () => { 7 | let req: Request; 8 | let res: Response; 9 | let next: NextFunction; 10 | 11 | beforeEach(() => { 12 | req = {} as Request; 13 | res = {} as Response; 14 | next = jest.fn(); 15 | }); 16 | 17 | it('should sanitize req.body, req.query, and req.params', () => { 18 | const options: SanitizeOptions = { 19 | whiteList: { b: [] } 20 | }; 21 | req.body = { a: '', b: 'good' }; 22 | req.query = { a: '', b: 'good' }; 23 | req.params = { a: '', b: 'good' }; 24 | 25 | xssMiddleware(options)(req, res, next); 26 | 27 | expect(req.body).toEqual({ 28 | a: '<script>bad()</script>', 29 | b: 'good' 30 | }); 31 | expect(req.query).toEqual({ 32 | a: '<script>bad()</script>', 33 | b: 'good' 34 | }); 35 | expect(req.params).toEqual({ 36 | a: '<script>bad()</script>', 37 | b: 'good' 38 | }); 39 | }); 40 | 41 | it('should call next', () => { 42 | xssMiddleware()(req, res, next); 43 | expect(next).toHaveBeenCalled(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /__test__/utils/compressFilter.test.ts: -------------------------------------------------------------------------------- 1 | import compression from 'compression'; 2 | import type { Request, Response } from 'express'; 3 | import compressFilter from '../../src/utils/compressFilter.util'; 4 | 5 | jest.mock('compression'); 6 | 7 | describe('compressFilter', () => { 8 | const mockRequest = {} as Request; 9 | const mockResponse = {} as Response; 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | }); 14 | 15 | it('should return false if x-no-compression header is present', () => { 16 | mockRequest.headers = { 'x-no-compression': 'true' }; 17 | const result = compressFilter(mockRequest, mockResponse); 18 | expect(result).toBe(false); 19 | expect(compression.filter).not.toHaveBeenCalled(); 20 | }); 21 | 22 | it('should use compression.filter if x-no-compression header is not present', () => { 23 | mockRequest.headers = {}; 24 | ( 25 | compression.filter as jest.MockedFunction 26 | ).mockReturnValueOnce(true); 27 | const result = compressFilter(mockRequest, mockResponse); 28 | expect(result).toBe(true); 29 | expect(compression.filter).toHaveBeenCalledWith(mockRequest, mockResponse); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /__test__/utils/sanitize.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-confusing-void-expression */ 2 | import type { SanitizeOptions } from '../../src/types/types'; 3 | import { sanitize } from '../../src/utils/sanitize.util'; 4 | 5 | describe('sanitize', () => { 6 | it('should return empty data as is', () => { 7 | expect(sanitize(null)).toBeNull(); 8 | expect(sanitize(undefined)).toBeUndefined(); 9 | expect(sanitize('')).toBe(''); 10 | expect(sanitize({})).toEqual({}); 11 | expect(sanitize([])).toEqual([]); 12 | }); 13 | 14 | it('should sanitize strings', () => { 15 | const input = ''; 16 | const expected = '<script>alert("hello world!")</script>'; 17 | expect(sanitize(input)).toBe(expected); 18 | }); 19 | 20 | it('should sanitize objects', () => { 21 | const input = { 22 | name: '', 23 | age: 20, 24 | address: { 25 | street: '123 Main St', 26 | city: 'Springfield', 27 | state: 'IL', 28 | zip: '' 29 | }, 30 | friends: [ 31 | { name: '', age: 22 }, 32 | { name: 'Alice', age: '' } 33 | ] 34 | }; 35 | 36 | const expected = { 37 | name: '<script>alert("hello world!");</script>', 38 | age: 20, 39 | address: { 40 | street: '123 Main St', 41 | city: 'Springfield', 42 | state: 'IL', 43 | zip: '<script>alert("hello world!");</script>' 44 | }, 45 | friends: [ 46 | { 47 | name: '<script>alert("hello world!");</script>', 48 | age: 22 49 | }, 50 | { 51 | name: 'Alice', 52 | age: '<script>alert("hello world!");</script>' 53 | } 54 | ] 55 | }; 56 | 57 | expect(sanitize(input)).toEqual(expected); 58 | }); 59 | 60 | it('should sanitize arrays', () => { 61 | const input = [ 62 | '', 63 | 20, 64 | ['
test
'] 65 | ]; 66 | const expected = [ 67 | '<script>alert("hello world!")</script>', 68 | 20, 69 | ['<div>test</div>'] 70 | ]; 71 | expect(sanitize(input)).toEqual(expected); 72 | }); 73 | 74 | it('should sanitize an array of objects', () => { 75 | const input = [ 76 | { name: '', age: 20 }, 77 | { name: 'Alice', age: '' } 78 | ]; 79 | 80 | const expected = [ 81 | { 82 | name: '<script>alert("hello world!");</script>', 83 | age: 20 84 | }, 85 | { 86 | name: 'Alice', 87 | age: '<script>alert("hello world!");</script>' 88 | } 89 | ]; 90 | expect(sanitize(input)).toEqual(expected); 91 | }); 92 | 93 | it('should allow custom XSS whiteList', () => { 94 | const input = '
Example
'; 95 | const expected = '
Example
'; 96 | const options: SanitizeOptions = { 97 | whiteList: { 98 | div: ['*'], 99 | a: ['href', 'target'] 100 | } 101 | }; 102 | expect(sanitize(input, options)).toBe(expected); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /assets/refresh_token_rotation_flow_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Louis3797/express-ts-auth-service/fc6722badf8b43c02adba9de7e11bb342c09a6f1/assets/refresh_token_rotation_flow_diagram.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | # Express Server 5 | auth-service: 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | image: auth-service:latest 10 | container_name: auth-service 11 | ports: 12 | - '${PORT}:${PORT}' 13 | env_file: 14 | - ./.env 15 | environment: 16 | NODE_ENV: production 17 | DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD}@mysql:3307/${MYSQL_DATABASE} 18 | PORT: 4040 19 | depends_on: 20 | - mysql 21 | restart: unless-stopped 22 | command: ["dumb-init", "node", "dist/index.js"] 23 | 24 | # MySQL Database 25 | mysql: 26 | image: mysql:8.0 27 | container_name: mysql 28 | volumes: 29 | - db-data:/var/lib/mysql 30 | ports: 31 | - '3307:3306' 32 | environment: 33 | # ! dont use root user in prod 34 | MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} 35 | MYSQL_DATABASE: ${MYSQL_DATABASE} 36 | MYSQL_USER: root 37 | MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD} 38 | env_file: 39 | - ./.env 40 | restart: always 41 | volumes: 42 | db-data: 43 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | clearMocks: true, 6 | collectCoverage: true, 7 | collectCoverageFrom: ['src/**/*.ts'], 8 | coverageDirectory: 'coverage', 9 | coverageReporters: ['json', 'html', 'text'], 10 | modulePathIgnorePatterns: [ 11 | './dist', 12 | './coverage', 13 | './logs', 14 | './prisma', 15 | './assets', 16 | './node_modules', 17 | 'index.ts', 18 | 'app.ts', 19 | 'src/validations', // no need for testing validations 20 | 'src/routes' 21 | ], 22 | transform: { 23 | '^.+\\.m?[tj]sx?$': [ 24 | 'ts-jest', 25 | { 26 | tsconfig: './tsconfig.test.json' 27 | } 28 | ] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-ts-auth-service", 3 | "version": "1.0.0", 4 | "description": "A ready-to-use authentication service build with express.js, that provides secure and reliable authentication using JSON Web Tokens (JWT) and refresh token rotation", 5 | "author": "Louis-Kaan Ay", 6 | "license": "MIT", 7 | "private": false, 8 | "main": "./dist/index.js", 9 | "type": "commonjs", 10 | "scripts": { 11 | "dev": "tsc --project './tsconfig.build.json' --watch && cross-env NODE_ENV=development nodemon --trace-warnings ./dist/index.js", 12 | "build": "tsc --project './tsconfig.build.json'", 13 | "start": "yarn run build && cross-env NODE_ENV=production node --trace-warnings ./dist/index.js", 14 | "watch": "tsc --project './tsconfig.build.json' --watch", 15 | "lint": "eslint src/**/*.ts __test__/**/*.test.ts", 16 | "lint:fix": "eslint --fix --ext .", 17 | "prettier:format": "prettier --write .", 18 | "prettier:check": "prettier --check .", 19 | "prettier:format:file": "prettier --write ", 20 | "test": "jest", 21 | "test:watch": "jest -i --watchAll", 22 | "coverage": "jest -i --coverage", 23 | "prepare": "husky install" 24 | }, 25 | "dependencies": { 26 | "@prisma/client": "^4.11.0", 27 | "argon2": "^0.30.3", 28 | "compression": "^1.7.4", 29 | "cookie-parser": "^1.4.6", 30 | "cors": "^2.8.5", 31 | "dotenv": "^16.0.3", 32 | "eslint": "^8.35.0", 33 | "express": "^4.18.2", 34 | "express-rate-limit": "^6.7.0", 35 | "helmet": "^6.0.1", 36 | "http-status": "^1.6.2", 37 | "joi": "^17.8.4", 38 | "jsonwebtoken": "^9.0.0", 39 | "nodemailer": "^6.9.1", 40 | "ts-node": "^10.9.1", 41 | "utility-types": "^3.10.0", 42 | "uuid": "^9.0.0", 43 | "winston": "^3.8.2", 44 | "xss": "^1.0.14" 45 | }, 46 | "devDependencies": { 47 | "@types/argon2": "^0.15.0", 48 | "@types/compression": "^1.7.2", 49 | "@types/cookie-parser": "^1.4.3", 50 | "@types/cors": "^2.8.13", 51 | "@types/dotenv": "^8.2.0", 52 | "@types/express": "^4.17.17", 53 | "@types/jest": "^29.4.0", 54 | "@types/jsonwebtoken": "^9.0.1", 55 | "@types/nodemailer": "^6.4.7", 56 | "@types/supertest": "^2.0.12", 57 | "@types/uuid": "^9.0.1", 58 | "@typescript-eslint/eslint-plugin": "^5.54.0", 59 | "@typescript-eslint/parser": "^5.54.0", 60 | "cross-env": "^7.0.3", 61 | "eslint-config-prettier": "^8.6.0", 62 | "eslint-config-standard": "^17.0.0", 63 | "eslint-config-standard-with-typescript": "^34.0.0", 64 | "eslint-plugin-import": "^2.27.5", 65 | "eslint-plugin-n": "^15.6.1", 66 | "eslint-plugin-node": "^11.1.0", 67 | "eslint-plugin-promise": "^6.1.1", 68 | "eslint-plugin-security": "^1.7.1", 69 | "eslint-plugin-standard": "^5.0.0", 70 | "husky": "^8.0.3", 71 | "jest": "^29.4.3", 72 | "nodemon": "^2.0.21", 73 | "prettier": "^2.8.4", 74 | "prettier-eslint": "^15.0.1", 75 | "prisma": "^4.11.0", 76 | "supertest": "^6.3.3", 77 | "ts-jest": "^29.0.5", 78 | "typescript": "5.0.2" 79 | }, 80 | "repository": { 81 | "type": "git", 82 | "url": "https://github.com/Louis3797/express-ts-auth-service" 83 | }, 84 | "bugs": { 85 | "url": "https://github.com/Louis3797/express-ts-auth-service/issues" 86 | }, 87 | "homepage": "https://github.com/Louis3797/express-ts-auth-service", 88 | "lint-staged": { 89 | "**/*.{js,jsx,ts,tsx}": [ 90 | "npx prettier --write", 91 | "npx eslint --fix" 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /prisma/migrations/20230307220106_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `Account` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `userId` VARCHAR(191) NOT NULL, 5 | `type` VARCHAR(191) NOT NULL, 6 | `provider` VARCHAR(191) NOT NULL, 7 | `providerAccountId` VARCHAR(191) NOT NULL, 8 | `refresh_token` TEXT NULL, 9 | `access_token` TEXT NULL, 10 | `expires_at` INTEGER NULL, 11 | `token_type` VARCHAR(191) NULL, 12 | `scope` VARCHAR(191) NULL, 13 | `id_token` TEXT NULL, 14 | `session_state` VARCHAR(191) NULL, 15 | 16 | UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`), 17 | PRIMARY KEY (`id`) 18 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 19 | 20 | -- CreateTable 21 | CREATE TABLE `User` ( 22 | `id` VARCHAR(191) NOT NULL, 23 | `name` VARCHAR(191) NULL, 24 | `email` VARCHAR(191) NULL, 25 | `emailVerified` DATETIME(3) NULL, 26 | `image` VARCHAR(191) NULL, 27 | 28 | UNIQUE INDEX `User_email_key`(`email`), 29 | PRIMARY KEY (`id`) 30 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 31 | 32 | -- AddForeignKey 33 | ALTER TABLE `Account` ADD CONSTRAINT `Account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 34 | -------------------------------------------------------------------------------- /prisma/migrations/20230308154922_user_password/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `image` on the `User` table. All the data in the column will be lost. 5 | - Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty. 6 | - Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column. 7 | 8 | */ 9 | -- AlterTable 10 | ALTER TABLE `User` DROP COLUMN `image`, 11 | ADD COLUMN `password` VARCHAR(191) NOT NULL, 12 | MODIFY `name` VARCHAR(191) NOT NULL; 13 | -------------------------------------------------------------------------------- /prisma/migrations/20230309120100_refresh_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `RefreshToken` ( 3 | `token` VARCHAR(191) NOT NULL, 4 | `expires` DATETIME(3) NOT NULL, 5 | `userId` VARCHAR(191) NULL, 6 | 7 | PRIMARY KEY (`token`) 8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230309155008_removed_expires/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `expires` on the `RefreshToken` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `RefreshToken` DROP COLUMN `expires`; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230317200854_resest_token/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `userId` on table `RefreshToken` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE `RefreshToken` DROP FOREIGN KEY `RefreshToken_userId_fkey`; 9 | 10 | -- AlterTable 11 | ALTER TABLE `RefreshToken` MODIFY `userId` VARCHAR(191) NOT NULL; 12 | 13 | -- CreateTable 14 | CREATE TABLE `ResetToken` ( 15 | `token` VARCHAR(191) NOT NULL, 16 | `expiresAt` DATETIME(3) NOT NULL, 17 | `userId` VARCHAR(191) NOT NULL, 18 | 19 | PRIMARY KEY (`token`) 20 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 21 | 22 | -- AddForeignKey 23 | ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE `ResetToken` ADD CONSTRAINT `ResetToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 27 | -------------------------------------------------------------------------------- /prisma/migrations/20230317205303_email_verification_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `EmailVerificationToken` ( 3 | `token` VARCHAR(191) NOT NULL, 4 | `expiresAt` DATETIME(3) NOT NULL, 5 | `userId` VARCHAR(191) NOT NULL, 6 | 7 | PRIMARY KEY (`token`) 8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE `EmailVerificationToken` ADD CONSTRAINT `EmailVerificationToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230318163942_refresh_token_changes/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `RefreshToken` DROP PRIMARY KEY, 10 | ADD COLUMN `id` VARCHAR(191) NOT NULL, 11 | ADD PRIMARY KEY (`id`); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230318164340_refresh_token_changes_back/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `id` on the `RefreshToken` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `RefreshToken` DROP PRIMARY KEY, 10 | DROP COLUMN `id`, 11 | ADD PRIMARY KEY (`token`); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230330094356_changed_name/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `expires_at` on the `Account` table. All the data in the column will be lost. 5 | - The primary key for the `EmailVerificationToken` table will be changed. If it partially fails, the table could be left without primary key constraint. 6 | - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint. 7 | - The primary key for the `ResetToken` table will be changed. If it partially fails, the table could be left without primary key constraint. 8 | - A unique constraint covering the columns `[token]` on the table `EmailVerificationToken` will be added. If there are existing duplicate values, this will fail. 9 | - A unique constraint covering the columns `[token]` on the table `RefreshToken` will be added. If there are existing duplicate values, this will fail. 10 | - A unique constraint covering the columns `[token]` on the table `ResetToken` will be added. If there are existing duplicate values, this will fail. 11 | - Added the required column `expiresAt` to the `Account` table without a default value. This is not possible if the table is not empty. 12 | - The required column `id` was added to the `EmailVerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. 13 | - The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. 14 | - The required column `id` was added to the `ResetToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. 15 | 16 | */ 17 | -- AlterTable 18 | ALTER TABLE `Account` DROP COLUMN `expires_at`, 19 | ADD COLUMN `expiresAt` DATETIME(3) NOT NULL; 20 | 21 | -- AlterTable 22 | ALTER TABLE `EmailVerificationToken` DROP PRIMARY KEY, 23 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 24 | ADD COLUMN `id` VARCHAR(191) NOT NULL, 25 | ADD PRIMARY KEY (`id`); 26 | 27 | -- AlterTable 28 | ALTER TABLE `RefreshToken` DROP PRIMARY KEY, 29 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 30 | ADD COLUMN `id` VARCHAR(191) NOT NULL, 31 | ADD PRIMARY KEY (`id`); 32 | 33 | -- AlterTable 34 | ALTER TABLE `ResetToken` DROP PRIMARY KEY, 35 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 36 | ADD COLUMN `id` VARCHAR(191) NOT NULL, 37 | ADD PRIMARY KEY (`id`); 38 | 39 | -- AlterTable 40 | ALTER TABLE `User` ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); 41 | 42 | -- CreateIndex 43 | CREATE UNIQUE INDEX `EmailVerificationToken_token_key` ON `EmailVerificationToken`(`token`); 44 | 45 | -- CreateIndex 46 | CREATE UNIQUE INDEX `RefreshToken_token_key` ON `RefreshToken`(`token`); 47 | 48 | -- CreateIndex 49 | CREATE UNIQUE INDEX `ResetToken_token_key` ON `ResetToken`(`token`); 50 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Account { 14 | id String @id @default(cuid()) 15 | userId String 16 | type String 17 | provider String 18 | providerAccountId String 19 | refresh_token String? @db.Text 20 | access_token String? @db.Text 21 | expiresAt DateTime 22 | token_type String? 23 | scope String? 24 | id_token String? @db.Text 25 | session_state String? 26 | 27 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 28 | 29 | @@unique([provider, providerAccountId]) 30 | } 31 | 32 | model User { 33 | id String @id @default(cuid()) 34 | name String 35 | email String? @unique 36 | password String 37 | emailVerified DateTime? 38 | createdAt DateTime @default(now()) 39 | accounts Account[] 40 | refreshTokens RefreshToken[] 41 | resetToken ResetToken[] 42 | emailVerificationToken EmailVerificationToken[] 43 | } 44 | 45 | model RefreshToken { 46 | id String @id @default(cuid()) 47 | token String @unique 48 | user User @relation(fields: [userId], references: [id]) 49 | userId String 50 | createdAt DateTime @default(now()) 51 | } 52 | 53 | model ResetToken { 54 | id String @id @default(cuid()) 55 | token String @unique 56 | expiresAt DateTime 57 | user User @relation(fields: [userId], references: [id]) 58 | userId String 59 | createdAt DateTime @default(now()) 60 | } 61 | 62 | model EmailVerificationToken { 63 | id String @id @default(cuid()) 64 | token String @unique 65 | expiresAt DateTime 66 | user User @relation(fields: [userId], references: [id]) 67 | userId String 68 | createdAt DateTime @default(now()) 69 | } 70 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { type Express } from 'express'; 2 | import cors from 'cors'; 3 | import helmet from 'helmet'; 4 | import compression from 'compression'; 5 | import cookieParser from 'cookie-parser'; 6 | import compressFilter from './utils/compressFilter.util'; 7 | import { authRouter, passwordRouter, verifyEmailRouter } from './routes/v1'; 8 | import isAuth from './middleware/isAuth'; 9 | import { errorHandler } from './middleware/errorHandler'; 10 | import config from './config/config'; 11 | import authLimiter from './middleware/authLimiter'; 12 | import { xssMiddleware } from './middleware/xssMiddleware'; 13 | import path from 'path'; 14 | import corsConfig from './config/cors'; 15 | 16 | const app: Express = express(); 17 | 18 | // Helmet is used to secure this app by configuring the http-header 19 | app.use(helmet.frameguard({ action: 'deny' })); 20 | 21 | // parse json request body 22 | app.use(express.json()); 23 | 24 | // parse urlencoded request body 25 | app.use(express.urlencoded({ extended: true })); 26 | 27 | app.use(xssMiddleware()); 28 | 29 | app.use(cookieParser()); 30 | 31 | // Compression is used to reduce the size of the response body 32 | app.use(compression({ filter: compressFilter })); 33 | 34 | app.use(cors(corsConfig)); 35 | 36 | if (config.node_env === 'production') { 37 | app.use('/api/v1/auth', authLimiter); 38 | } 39 | 40 | app.use('/api/v1/auth', authRouter); 41 | 42 | app.use('/api/v1', passwordRouter); 43 | 44 | app.use('/api/v1', verifyEmailRouter); 45 | 46 | app.get('/secret', isAuth, (_req, res) => { 47 | res.json({ 48 | message: 'You can see me' 49 | }); 50 | }); 51 | 52 | app.all('*', (req, res) => { 53 | res.status(404); 54 | if (req.accepts('html')) { 55 | res.sendFile(path.join(__dirname, 'views', '404.html')); 56 | } else if (req.accepts('json')) { 57 | res.json({ error: '404 Not Found' }); 58 | } else { 59 | res.type('txt').send('404 Not Found'); 60 | } 61 | }); 62 | 63 | app.use(errorHandler); 64 | 65 | export default app; 66 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import path from 'path'; 3 | import Joi from 'joi'; 4 | 5 | dotenv.config({ 6 | path: path.resolve(__dirname, '../../.env') 7 | }); 8 | 9 | const envSchema = Joi.object().keys({ 10 | NODE_ENV: Joi.string().valid('production', 'development', 'test').required(), 11 | PORT: Joi.string().required().default('4000'), 12 | SERVER_URL: Joi.string().required(), 13 | CORS_ORIGIN: Joi.string().required().default('*'), 14 | ACCESS_TOKEN_SECRET: Joi.string().min(8).required(), 15 | ACCESS_TOKEN_EXPIRE: Joi.string().required().default('20m'), 16 | REFRESH_TOKEN_SECRET: Joi.string().min(8).required(), 17 | REFRESH_TOKEN_EXPIRE: Joi.string().required().default('1d'), 18 | REFRESH_TOKEN_COOKIE_NAME: Joi.string().required().default('jid'), 19 | MYSQL_DATABASE: Joi.string().required(), 20 | MYSQL_ROOT_PASSWORD: Joi.string().required(), 21 | DATABASE_URL: Joi.string().required(), 22 | SMTP_HOST: Joi.string().required(), 23 | SMTP_PORT: Joi.string().default('587'), 24 | SMTP_USERNAME: Joi.string().required(), 25 | SMTP_PASSWORD: Joi.string().required(), 26 | EMAIL_FROM: Joi.string().email().required() 27 | }); 28 | 29 | const { value: validatedEnv, error } = envSchema 30 | .prefs({ errors: { label: 'key' } }) 31 | .validate(process.env, { abortEarly: false, stripUnknown: true }); 32 | 33 | if (error) { 34 | throw new Error( 35 | `Environment variable validation error: \n${error.details 36 | .map((detail) => detail.message) 37 | .join('\n')}` 38 | ); 39 | } 40 | 41 | const config = { 42 | node_env: validatedEnv.NODE_ENV, 43 | server: { 44 | port: validatedEnv.PORT, 45 | url: validatedEnv.SERVER_URL 46 | }, 47 | cors: { 48 | cors_origin: validatedEnv.CORS_ORIGIN 49 | }, 50 | jwt: { 51 | access_token: { 52 | secret: validatedEnv.ACCESS_TOKEN_SECRET, 53 | expire: validatedEnv.ACCESS_TOKEN_EXPIRE 54 | }, 55 | refresh_token: { 56 | secret: validatedEnv.REFRESH_TOKEN_SECRET, 57 | expire: validatedEnv.REFRESH_TOKEN_EXPIRE, 58 | cookie_name: validatedEnv.REFRESH_TOKEN_COOKIE_NAME 59 | } 60 | }, 61 | email: { 62 | smtp: { 63 | host: validatedEnv.SMTP_HOST, 64 | port: validatedEnv.SMTP_PORT, 65 | auth: { 66 | username: validatedEnv.SMTP_USERNAME, 67 | password: validatedEnv.SMTP_PASSWORD 68 | } 69 | }, 70 | from: validatedEnv.EMAIL_FROM 71 | } 72 | } as const; 73 | 74 | export default config; 75 | -------------------------------------------------------------------------------- /src/config/cookieConfig.ts: -------------------------------------------------------------------------------- 1 | import type { CookieOptions } from 'express'; 2 | 3 | export const refreshTokenCookieConfig: CookieOptions = { 4 | httpOnly: true, 5 | sameSite: 'none', 6 | secure: true, 7 | maxAge: 24 * 60 * 60 * 1000 8 | }; 9 | 10 | export const clearRefreshTokenCookieConfig: CookieOptions = { 11 | httpOnly: true, 12 | sameSite: 'none', 13 | secure: true 14 | }; 15 | -------------------------------------------------------------------------------- /src/config/cors.ts: -------------------------------------------------------------------------------- 1 | import { type CorsOptions } from 'cors'; 2 | import config from './config'; 3 | 4 | const whitelist = String(config.cors.cors_origin).split('|') ?? []; 5 | 6 | const corsConfig: Readonly = { 7 | origin ( 8 | origin: string | undefined, 9 | callback: ( 10 | err: Error | null, 11 | origin?: boolean | string | RegExp | Array 12 | ) => void 13 | ) { 14 | if (!origin || whitelist.some((val) => origin.match(val))) { 15 | callback(null, true); 16 | } else { 17 | callback(new Error('Not allowed by CORS')); 18 | } 19 | }, 20 | maxAge: 86400, 21 | headers: [ 22 | 'Accept', 23 | 'Authorization', 24 | 'Content-Type', 25 | 'If-None-Match', 26 | 'BX-User-Token', 27 | 'Trace-Id' 28 | ], 29 | exposedHeaders: ['WWW-Authenticate', 'Server-Authorization'], 30 | credentials: true 31 | } as CorsOptions; 32 | 33 | export default corsConfig; 34 | -------------------------------------------------------------------------------- /src/config/nodemailer.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { type Transporter } from 'nodemailer'; 2 | import logger from '../middleware/logger'; 3 | import config from './config'; 4 | 5 | let transporter: Transporter | null = null; 6 | 7 | const createTestAccount = async () => { 8 | try { 9 | const account = await nodemailer.createTestAccount(); 10 | transporter = nodemailer.createTransport({ 11 | host: account.smtp.host, 12 | port: account.smtp.port, 13 | secure: account.smtp.secure, 14 | auth: { 15 | user: account.user, 16 | pass: account.pass 17 | } 18 | }); 19 | logger.info(`Test account created: ${account.user}`); 20 | console.log(account); 21 | } catch (error) { 22 | console.error('Failed to create a test account:', error); 23 | } 24 | }; 25 | 26 | if (config.node_env === 'production') { 27 | transporter = nodemailer.createTransport({ 28 | host: config.email.smtp.host, 29 | port: parseInt(config.email.smtp.port), 30 | secure: false, // true for 465, false for other ports 31 | auth: { 32 | user: config.email.smtp.auth.username, 33 | pass: config.email.smtp.auth.password 34 | } 35 | }); 36 | } else { 37 | void createTestAccount(); 38 | } 39 | 40 | export default transporter; 41 | -------------------------------------------------------------------------------- /src/config/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import config from './config'; 3 | 4 | declare global { 5 | // eslint-disable-next-line no-var 6 | var prisma: PrismaClient | undefined; 7 | } 8 | 9 | const prismaClient: PrismaClient = new PrismaClient(); 10 | 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error 12 | // @ts-ignore 13 | if (config.node_env !== 'production') globalThis.prisma = prismaClient; 14 | 15 | export default prismaClient; 16 | -------------------------------------------------------------------------------- /src/controller/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { randomUUID } from 'crypto'; 4 | import * as argon2 from 'argon2'; 5 | import jwt, { type JwtPayload } from 'jsonwebtoken'; 6 | import prismaClient from '../config/prisma'; 7 | import type { 8 | TypedRequest, 9 | UserLoginCredentials, 10 | UserSignUpCredentials 11 | } from '../types/types'; 12 | import { 13 | createAccessToken, 14 | createRefreshToken 15 | } from '../utils/generateTokens.util'; 16 | import config from '../config/config'; 17 | 18 | import { 19 | clearRefreshTokenCookieConfig, 20 | refreshTokenCookieConfig 21 | } from '../config/cookieConfig'; 22 | 23 | import { sendVerifyEmail } from '../utils/sendEmail.util'; 24 | import logger from '../middleware/logger'; 25 | 26 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 27 | // @ts-expect-error 28 | const { verify } = jwt; 29 | 30 | /** 31 | * This function handles the signup process for new users. It expects a request object with the following properties: 32 | * 33 | * @param {TypedRequest} req - The request object that includes user's username, email, and password. 34 | * @param {Response} res - The response object that will be used to send the HTTP response. 35 | * 36 | * @returns {Response} Returns an HTTP response that includes one of the following: 37 | * - A 400 BAD REQUEST status code and an error message if the request body is missing any required parameters. 38 | * - A 409 CONFLICT status code if the user email already exists in the database. 39 | * - A 201 CREATED status code and a success message if the new user is successfully created and a verification email is sent. 40 | * - A 500 INTERNAL SERVER ERROR status code if there is an error in the server. 41 | */ 42 | export const handleSignUp = async ( 43 | req: TypedRequest, 44 | res: Response 45 | ) => { 46 | const { username, email, password } = req.body; 47 | 48 | // check req.body values 49 | if (!username || !email || !password) { 50 | return res.status(httpStatus.BAD_REQUEST).json({ 51 | message: 'Username, email and password are required!' 52 | }); 53 | } 54 | 55 | const checkUserEmail = await prismaClient.user.findUnique({ 56 | where: { 57 | email 58 | } 59 | }); 60 | 61 | if (checkUserEmail) return res.sendStatus(httpStatus.CONFLICT); // email is already in db 62 | 63 | try { 64 | const hashedPassword = await argon2.hash(password); 65 | 66 | const newUser = await prismaClient.user.create({ 67 | data: { 68 | name: username, 69 | email, 70 | password: hashedPassword 71 | } 72 | }); 73 | 74 | const token = randomUUID(); 75 | const expiresAt = new Date(Date.now() + 3600000); // Token expires in 1 hour 76 | 77 | await prismaClient.emailVerificationToken.create({ 78 | data: { 79 | token, 80 | expiresAt, 81 | userId: newUser.id 82 | } 83 | }); 84 | 85 | // Send an email with the verification link 86 | sendVerifyEmail(email, token); 87 | 88 | res.status(httpStatus.CREATED).json({ message: 'New user created' }); 89 | } catch (err) { 90 | res.status(httpStatus.INTERNAL_SERVER_ERROR); 91 | } 92 | }; 93 | 94 | /** 95 | * This function handles the login process for users. It expects a request object with the following properties: 96 | * 97 | * @param {TypedRequest} req - The request object that includes user's email and password. 98 | * @param {Response} res - The response object that will be used to send the HTTP response. 99 | * 100 | * @returns {Response} Returns an HTTP response that includes one of the following: 101 | * - A 400 BAD REQUEST status code and an error message if the request body is missing any required parameters. 102 | * - A 401 UNAUTHORIZED status code if the user email does not exist in the database or the email is not verified or the password is incorrect. 103 | * - A 200 OK status code and an access token if the login is successful and a new refresh token is stored in the database and a new refresh token cookie is set. 104 | * - A 500 INTERNAL SERVER ERROR status code if there is an error in the server. 105 | */ 106 | export const handleLogin = async ( 107 | req: TypedRequest, 108 | res: Response 109 | ) => { 110 | const cookies = req.cookies; 111 | 112 | const { email, password } = req.body; 113 | 114 | if (!email || !password) { 115 | return res 116 | .status(httpStatus.BAD_REQUEST) 117 | .json({ message: 'Email and password are required!' }); 118 | } 119 | 120 | const user = await prismaClient.user.findUnique({ 121 | where: { 122 | email 123 | } 124 | }); 125 | 126 | if (!user) return res.sendStatus(httpStatus.UNAUTHORIZED); 127 | 128 | // check if email is verified 129 | if (!user.emailVerified) { 130 | res.status(httpStatus.UNAUTHORIZED).json({ 131 | message: 'Your email is not verified! Please confirm your email!' 132 | }); 133 | } 134 | 135 | // Generating the dummy hash dynamically may introduce a slight performance overhead, as argon2.hash() 136 | // is a computationally expensive operation. 137 | // However, this is generally negligible compared to the security benefits it provides 138 | const dummyPassword = 'dummy_password'; 139 | const dummyHash = await argon2.hash(dummyPassword); 140 | 141 | // Use the user's hash if found, otherwise use the dummy hash 142 | const userPasswordHash = user ? user.password : dummyHash; 143 | 144 | // check password 145 | try { 146 | const isPasswordValid = await argon2.verify(userPasswordHash, password); 147 | 148 | // Check if email is verified 149 | // Check for verified email after verifying the password to prevent user enumeration attacks 150 | if (!user.emailVerified) { 151 | return res.status(httpStatus.UNAUTHORIZED).json({ 152 | message: 'Your email is not verified! Please confirm your email!' 153 | }); 154 | } 155 | 156 | // If password is invalid, return unauthorized 157 | if (!isPasswordValid) { 158 | return res.status(httpStatus.UNAUTHORIZED).json({ 159 | message: 'Invalid email or password!' 160 | }); 161 | } 162 | 163 | // if there is a refresh token in the req.cookie, then we need to check if this 164 | // refresh token exists in the database and belongs to the current user than we need to delete it 165 | // if the token does not belong to the current user, then we delete all refresh tokens 166 | // of the user stored in the db to be on the safe site 167 | // we also clear the cookie in both cases 168 | if (cookies?.[config.jwt.refresh_token.cookie_name]) { 169 | // check if the given refresh token is from the current user 170 | const checkRefreshToken = await prismaClient.refreshToken.findUnique({ 171 | where: { 172 | token: cookies[config.jwt.refresh_token.cookie_name] 173 | } 174 | }); 175 | 176 | // if this token does not exists int the database or belongs to another user, 177 | // then we clear all refresh tokens from the user in the db 178 | if (!checkRefreshToken || checkRefreshToken.userId !== user.id) { 179 | await prismaClient.refreshToken.deleteMany({ 180 | where: { 181 | userId: user.id 182 | } 183 | }); 184 | } else { 185 | // else everything is fine and we just need to delete the one token 186 | await prismaClient.refreshToken.delete({ 187 | where: { 188 | token: cookies[config.jwt.refresh_token.cookie_name] 189 | } 190 | }); 191 | } 192 | 193 | // also clear the refresh token in the cookie 194 | res.clearCookie( 195 | config.jwt.refresh_token.cookie_name, 196 | clearRefreshTokenCookieConfig 197 | ); 198 | } 199 | 200 | const accessToken = createAccessToken(user.id); 201 | 202 | const newRefreshToken = createRefreshToken(user.id); 203 | 204 | // store new refresh token in db 205 | await prismaClient.refreshToken.create({ 206 | data: { 207 | token: newRefreshToken, 208 | userId: user.id 209 | } 210 | }); 211 | 212 | // save refresh token in cookie 213 | res.cookie( 214 | config.jwt.refresh_token.cookie_name, 215 | newRefreshToken, 216 | refreshTokenCookieConfig 217 | ); 218 | 219 | // send access token per json to user so it can be stored in the localStorage 220 | return res.json({ accessToken }); 221 | } catch (err) { 222 | return res.status(httpStatus.INTERNAL_SERVER_ERROR); 223 | } 224 | }; 225 | 226 | /** 227 | * This function handles the logout process for users. It expects a request object with the following properties: 228 | * 229 | * @param {TypedRequest} req - The request object that includes a cookie with a valid refresh token 230 | * @param {Response} res - The response object that will be used to send the HTTP response. 231 | * 232 | * @returns {Response} Returns an HTTP response that includes one of the following: 233 | * - A 204 NO CONTENT status code if the refresh token cookie is undefined 234 | * - A 204 NO CONTENT status code if the refresh token does not exists in the database 235 | * - A 204 NO CONTENT status code if the refresh token cookie is successfully cleared 236 | */ 237 | export const handleLogout = async (req: TypedRequest, res: Response) => { 238 | const cookies = req.cookies; 239 | 240 | if (!cookies[config.jwt.refresh_token.cookie_name]) { 241 | return res.sendStatus(httpStatus.NO_CONTENT); // No content 242 | } 243 | const refreshToken = cookies[config.jwt.refresh_token.cookie_name]; 244 | 245 | // Is refreshToken in db? 246 | const foundRft = await prismaClient.refreshToken.findUnique({ 247 | where: { token: refreshToken } 248 | }); 249 | 250 | if (!foundRft) { 251 | res.clearCookie( 252 | config.jwt.refresh_token.cookie_name, 253 | clearRefreshTokenCookieConfig 254 | ); 255 | return res.sendStatus(httpStatus.NO_CONTENT); 256 | } 257 | 258 | // Delete refreshToken in db 259 | await prismaClient.refreshToken.delete({ 260 | where: { token: refreshToken } 261 | }); 262 | 263 | res.clearCookie( 264 | config.jwt.refresh_token.cookie_name, 265 | clearRefreshTokenCookieConfig 266 | ); 267 | return res.sendStatus(httpStatus.NO_CONTENT); 268 | }; 269 | 270 | /** 271 | * This function handles the refresh process for users. It expects a request object with the following properties: 272 | * 273 | * @param {Request} req - The request object that includes a cookie with a valid refresh token 274 | * @param {Response} res - The response object that will be used to send the HTTP response. 275 | * 276 | * @returns {Response} Returns an HTTP response that includes one of the following: 277 | * - A 401 UNAUTHORIZED status code if the refresh token cookie is undefined 278 | * - A 403 FORBIDDEN status code if a refresh token reuse was detected but the token wasn't valid 279 | * - A 403 FORBIDDEN status code if a refresh token reuse was detected but the token was valid 280 | * - A 403 FORBIDDEN status code if the token wasn't valid 281 | * - A 200 OK status code if the token was valid and the user was granted a new refresh and access token 282 | */ 283 | export const handleRefresh = async (req: Request, res: Response) => { 284 | const refreshToken: string | undefined = 285 | req.cookies[config.jwt.refresh_token.cookie_name]; 286 | 287 | if (!refreshToken) return res.sendStatus(httpStatus.UNAUTHORIZED); 288 | 289 | // clear refresh cookie 290 | res.clearCookie( 291 | config.jwt.refresh_token.cookie_name, 292 | clearRefreshTokenCookieConfig 293 | ); 294 | 295 | // check if refresh token is in db 296 | const foundRefreshToken = await prismaClient.refreshToken.findUnique({ 297 | where: { 298 | token: refreshToken 299 | } 300 | }); 301 | 302 | // Detected refresh token reuse! 303 | if (!foundRefreshToken) { 304 | verify( 305 | refreshToken, 306 | config.jwt.refresh_token.secret, 307 | async (err: unknown, payload: JwtPayload) => { 308 | if (err) return res.sendStatus(httpStatus.FORBIDDEN); 309 | 310 | logger.warn('Attempted refresh token reuse!'); 311 | 312 | // Delete all tokens of the user because we detected that a token was stolen from him 313 | await prismaClient.refreshToken.deleteMany({ 314 | where: { 315 | userId: payload.userId 316 | } 317 | }); 318 | } 319 | ); 320 | return res.status(httpStatus.FORBIDDEN); 321 | } 322 | 323 | // delete from db 324 | await prismaClient.refreshToken.delete({ 325 | where: { 326 | token: refreshToken 327 | } 328 | }); 329 | 330 | // evaluate jwt 331 | verify( 332 | refreshToken, 333 | config.jwt.refresh_token.secret, 334 | async (err: unknown, payload: JwtPayload) => { 335 | if (err || foundRefreshToken.userId !== payload.userId) { 336 | return res.sendStatus(httpStatus.FORBIDDEN); 337 | } 338 | 339 | // Refresh token was still valid 340 | const accessToken = createAccessToken(payload.userId); 341 | 342 | const newRefreshToken = createRefreshToken(payload.userId); 343 | 344 | // add refresh token to db 345 | await prismaClient.refreshToken 346 | .create({ 347 | data: { 348 | token: newRefreshToken, 349 | userId: payload.userId 350 | } 351 | }) 352 | .catch((err: Error) => { 353 | logger.error(err); 354 | }); 355 | 356 | // Creates Secure Cookie with refresh token 357 | res.cookie( 358 | config.jwt.refresh_token.cookie_name, 359 | newRefreshToken, 360 | refreshTokenCookieConfig 361 | ); 362 | 363 | return res.json({ accessToken }); 364 | } 365 | ); 366 | }; 367 | -------------------------------------------------------------------------------- /src/controller/forgotPassword.controller.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { randomUUID } from 'crypto'; 4 | import * as argon2 from 'argon2'; 5 | import prismaClient from '../config/prisma'; 6 | import type { 7 | EmailRequestBody, 8 | ResetPasswordRequestBodyType, 9 | TypedRequest 10 | } from '../types/types'; 11 | import { sendResetEmail } from '../utils/sendEmail.util'; 12 | 13 | /** 14 | * Sends Forgot password email 15 | * @param req 16 | * @param res 17 | * @returns 18 | */ 19 | export const handleForgotPassword = async ( 20 | req: TypedRequest, 21 | res: Response 22 | ) => { 23 | const { email } = req.body; 24 | 25 | // check req.body values 26 | if (!email) { 27 | return res.status(httpStatus.BAD_REQUEST).json({ 28 | message: 'Email is required!' 29 | }); 30 | } 31 | 32 | // Check if the email exists in the database 33 | const user = await prismaClient.user.findUnique({ where: { email } }); 34 | 35 | // check if email is verified 36 | if (!user || user.emailVerified) { 37 | return res.send(httpStatus.UNAUTHORIZED).json({ 38 | message: 'Your email is not verified! Please confirm your email!' 39 | }); 40 | } 41 | 42 | // Generate a reset token and save it to the database 43 | const resetToken = randomUUID(); 44 | const expiresAt = new Date(Date.now() + 3600000); // Token expires in 1 hour 45 | await prismaClient.resetToken.create({ 46 | data: { 47 | token: resetToken, 48 | expiresAt, 49 | userId: user.id 50 | } 51 | }); 52 | 53 | // Send an email with the reset link 54 | sendResetEmail(email, resetToken); 55 | 56 | // Return a success message 57 | return res 58 | .status(httpStatus.OK) 59 | .json({ message: 'Password reset email sent' }); 60 | }; 61 | 62 | /** 63 | * Handles Password reset 64 | * @param req 65 | * @param res 66 | * @returns 67 | */ 68 | export const handleResetPassword = async ( 69 | req: TypedRequest, 70 | res: Response 71 | ) => { 72 | const { token } = req.params; 73 | const { newPassword } = req.body; 74 | 75 | if (!token) return res.sendStatus(httpStatus.NOT_FOUND); 76 | 77 | if (!newPassword) { 78 | return res 79 | .status(httpStatus.BAD_REQUEST) 80 | .json({ message: 'New password is required!' }); 81 | } 82 | 83 | // Check if the token exists in the database and is not expired 84 | const resetToken = await prismaClient.resetToken.findFirst({ 85 | where: { token, expiresAt: { gt: new Date() } } 86 | }); 87 | 88 | if (!resetToken) { 89 | return res 90 | .status(httpStatus.NOT_FOUND) 91 | .json({ error: 'Invalid or expired token' }); 92 | } 93 | 94 | // Update the user's password in the database 95 | const hashedPassword = await argon2.hash(newPassword); 96 | await prismaClient.user.update({ 97 | where: { id: resetToken.userId }, 98 | data: { password: hashedPassword } 99 | }); 100 | 101 | // Delete the reset and all other reset tokens that the user owns from the database 102 | await prismaClient.resetToken.deleteMany({ 103 | where: { userId: resetToken.userId } 104 | }); 105 | 106 | // Delete also all refresh tokens 107 | await prismaClient.refreshToken.deleteMany({ 108 | where: { 109 | userId: resetToken.userId 110 | } 111 | }); 112 | 113 | // Return a success message 114 | return res 115 | .status(httpStatus.OK) 116 | .json({ message: 'Password reset successful' }); 117 | }; 118 | -------------------------------------------------------------------------------- /src/controller/verifyEmail.controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { randomUUID } from 'crypto'; 4 | import prismaClient from '../config/prisma'; 5 | import type { EmailRequestBody, TypedRequest } from '../types/types'; 6 | import { sendVerifyEmail } from '../utils/sendEmail.util'; 7 | 8 | /** 9 | * Sends Verification email 10 | * @param req 11 | * @param res 12 | * @returns 13 | */ 14 | export const sendVerificationEmail = async ( 15 | req: TypedRequest, 16 | res: Response 17 | ) => { 18 | const { email } = req.body; 19 | 20 | if (!email) { 21 | return res 22 | .status(httpStatus.BAD_REQUEST) 23 | .json({ message: 'Email is required!' }); 24 | } 25 | 26 | // Check if the email exists in the database 27 | const user = await prismaClient.user.findUnique({ 28 | where: { email }, 29 | select: { id: true, emailVerified: true } 30 | }); 31 | 32 | if (!user) { 33 | return res 34 | .status(httpStatus.UNAUTHORIZED) 35 | .json({ error: 'Email not found' }); 36 | } 37 | 38 | // Check if the user's email is already verified 39 | if (user.emailVerified) { 40 | return res 41 | .status(httpStatus.CONFLICT) 42 | .json({ error: 'Email already verified' }); 43 | } 44 | 45 | // Check if there is an existing verification token that has not expired 46 | const existingToken = await prismaClient.emailVerificationToken.findFirst({ 47 | where: { 48 | user: { id: user.id }, 49 | expiresAt: { gt: new Date() } 50 | } 51 | }); 52 | 53 | if (existingToken) { 54 | return res 55 | .status(httpStatus.BAD_REQUEST) 56 | .json({ error: 'Verification email already sent' }); 57 | } 58 | 59 | // Generate a new verification token and save it to the database 60 | const token = randomUUID(); 61 | const expiresAt = new Date(Date.now() + 3600000); // Token expires in 1 hour 62 | await prismaClient.emailVerificationToken.create({ 63 | data: { 64 | token, 65 | expiresAt, 66 | userId: user.id 67 | } 68 | }); 69 | 70 | // Send an email with the new verification link 71 | sendVerifyEmail(email, token); 72 | 73 | // Return a success message 74 | return res.status(httpStatus.OK).json({ message: 'Verification email sent' }); 75 | }; 76 | 77 | export const handleVerifyEmail = async (req: Request, res: Response) => { 78 | const { token } = req.params; 79 | 80 | if (!token) return res.sendStatus(httpStatus.NOT_FOUND); 81 | 82 | // Check if the token exists in the database and is not expired 83 | const verificationToken = await prisma?.emailVerificationToken.findUnique({ 84 | where: { token } 85 | }); 86 | 87 | if (!verificationToken || verificationToken.expiresAt < new Date()) { 88 | return res 89 | .status(httpStatus.NOT_FOUND) 90 | .json({ error: 'Invalid or expired token' }); 91 | } 92 | 93 | // Update the user's email verification status in the database 94 | await prismaClient.user.update({ 95 | where: { id: verificationToken.userId }, 96 | data: { emailVerified: new Date() } 97 | }); 98 | 99 | // Delete the verification tokens that the user owns form the database 100 | await prismaClient.emailVerificationToken.deleteMany({ 101 | where: { userId: verificationToken.userId } 102 | }); 103 | 104 | // Return a success message 105 | return res.status(200).json({ message: 'Email verification successful' }); 106 | }; 107 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import config from './config/config'; 3 | import logger from './middleware/logger'; 4 | 5 | const server = app.listen(Number(config.server.port), () => { 6 | logger.log('info', `Server is running on Port: ${config.server.port}`); 7 | }); 8 | 9 | process.on('SIGTERM', () => { 10 | logger.info('SIGTERM signal received.'); 11 | logger.info('Closing server.'); 12 | server.close((err) => { 13 | logger.info('Server closed.'); 14 | // eslint-disable-next-line no-process-exit 15 | process.exit(err ? 1 : 0); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/middleware/authLimiter.ts: -------------------------------------------------------------------------------- 1 | import { rateLimit } from 'express-rate-limit'; 2 | 3 | const authLimiter = rateLimit({ 4 | windowMs: 15 * 60 * 1000, 5 | max: 20, 6 | skipSuccessfulRequests: true 7 | }); 8 | 9 | export default authLimiter; 10 | -------------------------------------------------------------------------------- /src/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import logger from './logger'; 3 | 4 | export const errorHandler = ( 5 | err: Error, 6 | _req: Request, 7 | res: Response 8 | ): void => { 9 | logger.error(err); 10 | 11 | res.status(500).json({ message: err.message }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/middleware/isAuth.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import type { NextFunction, Request, Response } from 'express'; 3 | import httpStatus from 'http-status'; 4 | 5 | import jwt, { type JwtPayload } from 'jsonwebtoken'; 6 | import config from '../config/config'; 7 | 8 | // Why does 'jsonwebtoken' not support es6 module support ????? 9 | // Maybe in future this will be added..... 10 | // GitHub Issue for this problem: https://github.com/auth0/node-jsonwebtoken/issues/655 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-expect-error 13 | const { verify } = jwt; 14 | 15 | const isAuth = (req: Request, res: Response, next: NextFunction) => { 16 | // token looks like 'Bearer vnjaknvijdaknvikbnvreiudfnvriengviewjkdsbnvierj' 17 | 18 | const authHeader = req.headers?.authorization; 19 | 20 | if (!authHeader || !authHeader?.startsWith('Bearer ')) { 21 | return res.sendStatus(httpStatus.UNAUTHORIZED); 22 | } 23 | 24 | const token: string | undefined = authHeader.split(' ')[1]; 25 | 26 | if (!token) return res.sendStatus(httpStatus.UNAUTHORIZED); 27 | 28 | verify( 29 | token, 30 | config.jwt.access_token.secret, 31 | (err: unknown, payload: JwtPayload) => { 32 | if (err) return res.sendStatus(httpStatus.FORBIDDEN); // invalid token 33 | req.payload = payload; 34 | 35 | next(); 36 | } 37 | ); 38 | }; 39 | 40 | export default isAuth; 41 | -------------------------------------------------------------------------------- /src/middleware/logger.ts: -------------------------------------------------------------------------------- 1 | import config from '../config/config'; 2 | import { createLogger, format, transports } from 'winston'; 3 | 4 | const logger = createLogger({ 5 | level: config.node_env === 'production' ? 'info' : 'debug', 6 | format: format.combine( 7 | format.timestamp({ 8 | format: 'YYYY-MM-DD HH:mm:ss' 9 | }), 10 | format.errors({ stack: true }), 11 | format.splat(), 12 | format.json(), 13 | format.printf(({ timestamp, level, message, stack }) => { 14 | return `${timestamp} [${level.toUpperCase()}] ${message} ${ 15 | stack ? `\n${stack}` : '' 16 | }`; 17 | }) 18 | ), 19 | transports: [ 20 | new transports.Console({ 21 | stderrLevels: ['error'] 22 | }), 23 | new transports.File({ 24 | filename: 'logs/error.log', 25 | level: 'error' 26 | }), 27 | new transports.File({ filename: 'logs/combined.log' }) 28 | ] 29 | }); 30 | export default logger; 31 | -------------------------------------------------------------------------------- /src/middleware/validate.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import Joi, { type ObjectSchema } from 'joi'; 4 | import type { RequireAtLeastOne } from '../types/types'; 5 | 6 | type RequestValidationSchema = RequireAtLeastOne< 7 | Record<'body' | 'query' | 'params', ObjectSchema> 8 | >; 9 | 10 | /** 11 | * This functions handles the validation of the given request validation schema 12 | * 13 | * @param {RequestValidationSchema} schema - The schema object can contain optional body, query, and params keys, each with a Joi schema object 14 | * 15 | * @returns Returns an HTTP response 400 BAD REQUEST if a validation error occurs or calls next if no error occurs 16 | * 17 | */ 18 | const validate = 19 | (schema: RequestValidationSchema) => 20 | (req: Request, res: Response, next: NextFunction) => { 21 | const { error } = Joi.object(schema).validate( 22 | { 23 | body: req.body, 24 | query: req.query, 25 | params: req.params 26 | }, 27 | { abortEarly: false, stripUnknown: true } 28 | ); 29 | if (!error) { 30 | next(); 31 | } else { 32 | const errors = error?.details.map((err) => ({ 33 | field: err.path.join(', '), 34 | message: err.message 35 | })); 36 | 37 | res.status(httpStatus.BAD_REQUEST).json({ errors }); 38 | } 39 | }; 40 | 41 | export default validate; 42 | -------------------------------------------------------------------------------- /src/middleware/xssMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { ParamsDictionary } from 'express-serve-static-core'; 2 | import type { ParsedQs } from 'qs'; 3 | import { sanitize } from '../utils/sanitize.util'; 4 | import type { ExpressMiddleware, SanitizeOptions } from '../types/types'; 5 | 6 | export const xssMiddleware = (options?: SanitizeOptions): ExpressMiddleware => { 7 | return (req, _res, next) => { 8 | req.body = sanitize(req.body, options); 9 | req.query = sanitize(req.query, options) as unknown as ParsedQs; 10 | req.params = sanitize(req.params, options) as unknown as ParamsDictionary; 11 | 12 | next(); 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/routes/v1/auth.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import validate from '../../middleware/validate'; 3 | import { loginSchema, signupSchema } from '../../validations/auth.validation'; 4 | import * as authController from '../../controller/auth.controller'; 5 | 6 | const authRouter = Router(); 7 | 8 | authRouter.post('/signup', validate(signupSchema), authController.handleSignUp); 9 | 10 | authRouter.post('/login', validate(loginSchema), authController.handleLogin); 11 | 12 | authRouter.post('/logout', authController.handleLogout); 13 | 14 | authRouter.post('/refresh', authController.handleRefresh); 15 | 16 | export default authRouter; 17 | -------------------------------------------------------------------------------- /src/routes/v1/index.ts: -------------------------------------------------------------------------------- 1 | import authRouter from './auth.route'; 2 | import passwordRouter from './password.route'; 3 | import verifyEmailRouter from './verifyEmail.route'; 4 | 5 | export { authRouter, verifyEmailRouter, passwordRouter }; 6 | -------------------------------------------------------------------------------- /src/routes/v1/password.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import validate from '../../middleware/validate'; 3 | import { 4 | forgotPasswordSchema, 5 | resetPasswordSchema 6 | } from '../../validations/password.validation'; 7 | import * as passwordController from '../../controller/forgotPassword.controller'; 8 | 9 | const passwordRouter = Router(); 10 | 11 | passwordRouter.post( 12 | '/forgot-password', 13 | validate(forgotPasswordSchema), 14 | passwordController.handleForgotPassword 15 | ); 16 | passwordRouter.post( 17 | '/reset-password/:token', 18 | validate(resetPasswordSchema), 19 | passwordController.handleResetPassword 20 | ); 21 | 22 | export default passwordRouter; 23 | -------------------------------------------------------------------------------- /src/routes/v1/verifyEmail.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import validate from '../../middleware/validate'; 3 | import { 4 | sendVerifyEmailSchema, 5 | verifyEmailSchema 6 | } from '../../validations/verifyEmail.validation'; 7 | import * as emailController from '../../controller/verifyEmail.controller'; 8 | 9 | const verifyEmailRouter = Router(); 10 | 11 | verifyEmailRouter.post( 12 | '/send-verification-email', 13 | validate(sendVerifyEmailSchema), 14 | emailController.sendVerificationEmail 15 | ); 16 | 17 | verifyEmailRouter.post( 18 | '/verify-email/:token', 19 | validate(verifyEmailSchema), 20 | emailController.handleVerifyEmail 21 | ); 22 | 23 | export default verifyEmailRouter; 24 | -------------------------------------------------------------------------------- /src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | readonly NODE_ENV: 'production' | 'development' | 'test'; 4 | readonly PORT: string; 5 | readonly SERVER_URL: string; 6 | readonly CORS_ORIGIN: string; 7 | readonly ACCESS_TOKEN_SECRET: string; 8 | readonly ACCESS_TOKEN_EXPIRE: string; 9 | readonly REFRESH_TOKEN_SECRET: string; 10 | readonly REFRESH_TOKEN_EXPIRE: string; 11 | readonly REFRESH_TOKEN_COOKIE_NAME: string; 12 | readonly MYSQL_DATABASE: string; 13 | readonly MYSQL_ROOT_PASSWORD: string; 14 | readonly DATABASE_URL: string; 15 | readonly SMTP_HOST: string; 16 | readonly SMTP_PORT: string; 17 | readonly SMTP_USERNAME: string; 18 | readonly SMTP_PASSWORD: string; 19 | readonly EMAIL_FROM: string; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import type { JwtPayload } from 'jsonwebtoken'; 2 | declare global { 3 | namespace Express { 4 | export interface Request { 5 | payload?: JwtPayload; 6 | 7 | cookies: { 8 | jid?: string; 9 | }; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/types/jwt.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jsonwebtoken' { 2 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 3 | export interface JwtPayload { 4 | userId: string; 5 | } 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 8 | export interface Jwt {} 9 | } 10 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import type { DeepPartial } from 'utility-types'; 3 | import type { IFilterXSSOptions } from 'xss'; 4 | 5 | // See this for the following types 6 | // https://stackoverflow.com/questions/34508081/how-to-add-typescript-definitions-to-express-req-res 7 | // https://stackoverflow.com/questions/61132262/typescript-deep-partial 8 | 9 | export type RequireAtLeastOne = { 10 | [K in keyof T]-?: Required> & 11 | Partial>>; 12 | }[keyof T]; 13 | 14 | // More strictly typed Express.Request type 15 | export type TypedRequest< 16 | ReqBody = Record, 17 | QueryString = Record 18 | > = Request< 19 | Record, 20 | Record, 21 | DeepPartial, 22 | DeepPartial 23 | >; 24 | 25 | // More strictly typed express middleware type 26 | export type ExpressMiddleware< 27 | ReqBody = Record, 28 | Res = Record, 29 | QueryString = Record 30 | > = ( 31 | req: TypedRequest, 32 | res: Response, 33 | next: NextFunction 34 | ) => Promise | void; 35 | 36 | // Example usage from Stackoverflow: 37 | // type Req = { email: string; password: string }; 38 | 39 | // type Res = { message: string }; 40 | 41 | // export const signupUser: ExpressMiddleware = async (req, res) => { 42 | // /* strongly typed `req.body`. yay autocomplete 🎉 */ 43 | // res.json({ message: 'you have signed up' }) // strongly typed response obj 44 | // }; 45 | export interface UserSignUpCredentials { 46 | username: string; 47 | email: string; 48 | password: string; 49 | } 50 | 51 | export type UserLoginCredentials = Omit; 52 | 53 | export interface EmailRequestBody { 54 | email: string; 55 | } 56 | 57 | export interface ResetPasswordRequestBodyType { 58 | newPassword: string; 59 | } 60 | 61 | export type Sanitized = T extends (...args: unknown[]) => unknown 62 | ? T // if T is a function, return it as is 63 | : T extends object 64 | ? { 65 | readonly [K in keyof T]: Sanitized; 66 | } 67 | : T; 68 | 69 | export type SanitizeOptions = IFilterXSSOptions & { 70 | whiteList?: IFilterXSSOptions['whiteList']; 71 | }; 72 | -------------------------------------------------------------------------------- /src/utils/compressFilter.util.ts: -------------------------------------------------------------------------------- 1 | import compression from 'compression'; 2 | import type { Request, Response } from 'express'; 3 | 4 | /** 5 | * Filter Function for the compression middleware 6 | * @param req HTTPs Request 7 | * @param res HTTPs Response 8 | * @returns Returns false if request header contains x-no-compression 9 | */ 10 | const compressFilter = (req: Request, res: Response): boolean => { 11 | if (req.headers['x-no-compression']) { 12 | // don't compress responses with this request header 13 | return false; 14 | } 15 | 16 | // fallback to standard filter function 17 | return compression.filter(req, res); 18 | }; 19 | 20 | export default compressFilter; 21 | -------------------------------------------------------------------------------- /src/utils/generateTokens.util.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import config from '../config/config'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-expect-error 6 | const { sign } = jwt; 7 | 8 | /** 9 | * This functions generates a valid access token 10 | * 11 | * @param {number | string} userId - The user id of the user that owns this jwt 12 | * @returns Returns a valid access token 13 | */ 14 | export const createAccessToken = (userId: number | string): string => { 15 | return sign({ userID: userId }, config.jwt.access_token.secret, { 16 | expiresIn: config.jwt.access_token.expire 17 | }); 18 | }; 19 | 20 | /** 21 | * This functions generates a valid refresh token 22 | * 23 | * @param {number | string} userId - The user id of the user that owns this jwt 24 | * @returns Returns a valid refresh token 25 | */ 26 | export const createRefreshToken = (userId: number | string): string => { 27 | return sign({ userId }, config.jwt.refresh_token.secret, { 28 | expiresIn: config.jwt.refresh_token.expire 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/sanitize.util.ts: -------------------------------------------------------------------------------- 1 | import type { Sanitized, SanitizeOptions } from '../types/types'; 2 | import xss from 'xss'; 3 | 4 | /** 5 | * Sanitizes the provided data by applying XSS filtering and returning a copy of the data with all properties and values set as readonly. 6 | * 7 | * @param data The data to be sanitized. 8 | * @param options The options to configure the XSS filtering process. 9 | * 10 | * @returns A sanitized copy of the provided data with all properties and values set as readonly. 11 | */ 12 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint 13 | export const sanitize = ( 14 | data: T, 15 | options?: SanitizeOptions 16 | ): Sanitized => { 17 | // If data is falsy, return as is 18 | if (!data) { 19 | return data as unknown as Sanitized; 20 | } 21 | 22 | // If data is an array, sanitize each item in the array and return the sanitized array 23 | if (Array.isArray(data)) { 24 | const sanitizedArray = data.map((item) => 25 | sanitize(item, options) 26 | ) as unknown as Sanitized; 27 | return sanitizedArray; 28 | } 29 | 30 | // If data is an object, sanitize each property value in the object and return the sanitized object 31 | if (typeof data === 'object' && data !== null) { 32 | const sanitizedObject = {} as { [K in keyof T]: Sanitized }; 33 | 34 | // Sanitize each property value in the object 35 | for (const [key, value] of Object.entries(data)) { 36 | sanitizedObject[key as keyof T] = sanitize(value, options); 37 | } 38 | return sanitizedObject as Sanitized; 39 | } 40 | 41 | // If data is a string, apply XSS filtering and return the sanitized string 42 | if (typeof data === 'string') { 43 | const xssOptions: SanitizeOptions = { 44 | stripIgnoreTagBody: options?.stripIgnoreTagBody ?? false, 45 | whiteList: options?.whiteList ?? {}, 46 | ...options 47 | }; 48 | return xss(data, xssOptions) as Sanitized; 49 | } 50 | 51 | // If data is not an array, object, or string, return as is 52 | return data as Sanitized; 53 | }; 54 | -------------------------------------------------------------------------------- /src/utils/sendEmail.util.ts: -------------------------------------------------------------------------------- 1 | import logger from '../middleware/logger'; 2 | import transporter from '../config/nodemailer'; 3 | import config from '../config/config'; 4 | 5 | /** 6 | * This function sends an email to the given email with the reset password link 7 | * 8 | * @param {string} email - The email of the user 9 | * @param {string} token - The reset password token 10 | */ 11 | export const sendResetEmail = (email: string, token: string) => { 12 | const resetLink = `${config.server.url}/api/v1/reset-password/${token}`; 13 | const mailOptions = { 14 | from: config.email.from, 15 | to: email, 16 | subject: 'Password reset', 17 | html: ` 18 |

Please reset your password by clicking the button below:

19 |
20 | 21 |
22 | ` 23 | }; 24 | console.log(resetLink); 25 | transporter?.sendMail(mailOptions, (error, info) => { 26 | if (error) { 27 | logger.error(error); 28 | } else { 29 | logger.info('Reset password email sent: ' + info.response); 30 | } 31 | }); 32 | }; 33 | 34 | /** 35 | * This function sends an email to the given email with the email verification link 36 | * 37 | * @param {string} email - The email of the user 38 | * @param {string} token - The email verification token 39 | */ 40 | export const sendVerifyEmail = (email: string, token: string) => { 41 | const verifyLink = `${config.server.url}/api/v1/verify-email/${token}`; 42 | const mailOptions = { 43 | from: config.email.from, 44 | to: email, 45 | subject: 'Email verification', 46 | html: ` 47 |

Please verify your email by clicking the button below:

48 |
49 | 50 |
51 | ` 52 | }; 53 | console.log(verifyLink); 54 | transporter?.sendMail(mailOptions, (error, info) => { 55 | if (error) { 56 | logger.error(error); 57 | } else { 58 | logger.info('Verify email sent: ' + info.response); 59 | } 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /src/validations/auth.validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import type { 3 | UserLoginCredentials, 4 | UserSignUpCredentials 5 | } from '../types/types'; 6 | 7 | export const signupSchema = { 8 | body: Joi.object().keys({ 9 | email: Joi.string().required().email(), 10 | password: Joi.string().required().min(6).max(150), 11 | username: Joi.string().required().min(2).max(50) 12 | }) 13 | }; 14 | 15 | export const loginSchema = { 16 | body: Joi.object().keys({ 17 | email: Joi.string().required().email(), 18 | password: Joi.string().required().min(6).max(150) 19 | }) 20 | }; 21 | -------------------------------------------------------------------------------- /src/validations/password.validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import type { EmailRequestBody } from '../types/types'; 3 | 4 | export const forgotPasswordSchema = { 5 | body: Joi.object().keys({ 6 | email: Joi.string().required().email() 7 | }) 8 | }; 9 | 10 | export const resetPasswordSchema = { 11 | body: Joi.object().keys({ 12 | newPassword: Joi.string().required().min(6).max(150) 13 | }), 14 | params: Joi.object().keys({ 15 | token: Joi.string().regex( 16 | /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*$/ 17 | ) 18 | }) 19 | }; 20 | -------------------------------------------------------------------------------- /src/validations/verifyEmail.validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import type { EmailRequestBody } from '../types/types'; 3 | 4 | export const sendVerifyEmailSchema = { 5 | body: Joi.object().keys({ 6 | email: Joi.string().required().email() 7 | }) 8 | }; 9 | 10 | export const verifyEmailSchema = { 11 | params: Joi.object().keys({ 12 | token: Joi.string().regex( 13 | /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*$/ 14 | ) 15 | }) 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true 5 | }, 6 | "include": ["src/**/*.ts"], 7 | "exclude": ["node_modules", "__test__", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2022", 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "baseUrl": ".", 13 | "sourceRoot": "./src", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "typeRoots": ["./node_modules/@types", "./src/types"], 17 | "lib": ["es2022", "dom"], 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | 21 | // strict settings 22 | "strict": true, 23 | "strictNullChecks": true, 24 | "allowUnusedLabels": false, 25 | "noImplicitAny": true, 26 | "noImplicitThis": true, 27 | "allowUnreachableCode": false, 28 | "exactOptionalPropertyTypes": true, 29 | "noFallthroughCasesInSwitch": true, 30 | "noImplicitOverride": true, 31 | "noImplicitReturns": false, // is false bc of some error with next() and other returns 32 | "noPropertyAccessFromIndexSignature": true, 33 | "noUncheckedIndexedAccess": true, 34 | "noUnusedLocals": true, 35 | "noUnusedParameters": true, 36 | "forceConsistentCasingInFileNames": true, 37 | }, 38 | } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest", "node"], 5 | "baseUrl": ".", 6 | "esModuleInterop": true, 7 | "module": "commonjs" 8 | }, 9 | "include": ["jest.config.ts", "src/**/*.ts", "__test__/**/*.test.ts", "**/*.test.ts"] 10 | } 11 | --------------------------------------------------------------------------------